From 1ba49ffa8d29b669e4f506f7221ae113f9eef03c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:31:07 +0900 Subject: [PATCH] =?UTF-8?q?v2026.09.30=5F001:=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85,=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=EB=A5=A0=20=C2=B7=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84,=20=EC=83=81=ED=83=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 버전 표기를 년.월.일_순번 으로 변경 (tool/next_version.ps1) - 동영상 목록 여러 개 선택 · 정렬, 자막 만들기 / 자막 만들기 & MKV 만들기 / 선택한 파일 재생 일괄 실행 - 다운로드: 동영상 추가 버튼, 완료시 자동 동영상추가, 받은 크기 / 전체 크기, yt-dlp 진행률 출력 복구 (--no-quiet --progress), 영상 + 음성 진행률 합산 - YouTube 쿠키를 못 읽으면 앱 안 브라우저 쿠키 → 쿠키 없이 순서로 재시도 - 모든 화면 오른쪽 위에 CPU · MEM · DISK, 환경 설정 · 종료 버튼 - 단축키로 창 보이기 / 숨기기 전환, 종료 시간 단축 (exit.log 에 단계별 시간) Co-Authored-By: Claude Opus 5.5 --- README.md | 7 + lib/app/app_controller.dart | 115 +++++++- lib/app/download_manager.dart | 24 +- lib/core/app_update.dart | 24 +- lib/main.dart | 21 +- lib/platform/windows/github_updater.dart | 8 +- lib/platform/windows/windows_usage.dart | 77 +++++ lib/platform/windows/ytdlp_backend.dart | 34 ++- lib/services/platform_services.dart | 7 + lib/services/system_usage.dart | 93 ++++++ lib/ui/ai_dialog.dart | 23 +- lib/ui/app_actions.dart | 129 +++++++++ lib/ui/browser_page.dart | 12 +- lib/ui/downloads_page.dart | 41 ++- lib/ui/home_page.dart | 164 ++++++++--- lib/ui/player_page.dart | 11 +- lib/ui/settings_page.dart | 12 +- lib/ui/subtitle_editor_page.dart | 6 +- pubspec.yaml | 2 +- test/batch_test.dart | 342 +++++++++++++++++++++++ test/update_test.dart | 18 ++ test/usage_test.dart | 94 +++++++ test/ytdlp_net_test.dart | 18 ++ tool/next_version.ps1 | 27 ++ 24 files changed, 1222 insertions(+), 87 deletions(-) create mode 100644 lib/platform/windows/windows_usage.dart create mode 100644 lib/services/system_usage.dart create mode 100644 lib/ui/app_actions.dart create mode 100644 test/batch_test.dart create mode 100644 test/usage_test.dart create mode 100644 tool/next_version.ps1 diff --git a/README.md b/README.md index 9b97aa2..a4d97b5 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ | **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 | | **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 | | **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 | +| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] | +| **상태 표시** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 환경 설정 · 종료 버튼 | ## 실행 @@ -59,6 +61,11 @@ OpenSubtitles 의 무료 API 키가 필요합니다. | 브라우저 | Ctrl+L 주소창 · F5 새로고침 · Alt+←/→ 뒤로/앞으로 · Ctrl+D 즐겨찾기 · Ctrl+Shift+B 즐겨찾기 관리 · Ctrl+Shift+J 작업 현황 | | 어디서나 | Ctrl+Shift+X 창 보이기 / 트레이로 숨기기 (환경 설정에서 변경) | +## 버전 + +`년.월.일_순번` (예: `2026.09.30_001`). 같은 날 다시 내면 순번이 늘고, 날이 바뀌면 `001` 부터 다시 시작합니다. +번호 올리기: `powershell -File tool\next_version.ps1` (pubspec.yaml 에는 Flutter 형식 `2026.9.30+1` 로 적힙니다) + ## 소스에서 빌드 필요: Flutter 3.47+, Visual Studio 2022 Build Tools (C++ 데스크톱), Windows 개발자 모드, PATH 에 `nuget.exe` (앱 안 브라우저 WebView2 빌드용) diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index f91b5c9..2150bd0 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -23,6 +23,7 @@ import '../services/ai_services.dart'; import '../services/media_tool.dart'; import '../services/model_store.dart'; import '../services/platform_services.dart'; +import '../services/system_usage.dart'; import 'settings.dart'; extension on Set { @@ -70,6 +71,54 @@ class AppController extends ChangeNotifier { Future Function()? confirmQuit; /// 설정 변경 후 저장·적용 + /// 위쪽 막대의 CPU · MEM 표시 (2초마다, 보는 화면이 있을 때만 잰다). 지원하지 않으면 null. + late final UsageMonitor? usage = + services.usage == null ? null : UsageMonitor(services.usage!, diskPath: _downloadDisk); + + /// 다운로드 폴더가 있는 드라이브 (예: "M:\\"). 설정이 바뀔 때만 다시 알아낸다. + String? _diskFor, _diskRoot; + String _downloadDisk() { + final key = settings.downloadRoot ?? ''; + if (_diskRoot == null || _diskFor != key) { + _diskFor = key; + try { + _diskRoot = p.rootPrefix(p.absolute(settings.resolvedDownloadRoot())); + } catch (_) { + _diskRoot = ''; + } + } + return _diskRoot!; + } + + // ───────── 목록 정렬 ───────── + + /// 마지막으로 누른 정렬 ('name' · 'date') 과 방향. 같은 것을 다시 누르면 반대로. + String? sortedBy; + bool sortAscending = true; + + /// 동영상 목록을 파일 이름 ('name') 또는 파일 날짜 ('date') 순으로 정렬 + void sortVideos(String by) { + sortAscending = sortedBy == by ? !sortAscending : true; + sortedBy = by; + final dates = {}; + if (by == 'date') { + for (final v in videos) { + try { + dates[v] = File(v.path).lastModifiedSync(); + } catch (_) { + dates[v] = DateTime.fromMillisecondsSinceEpoch(0); + } + } + } + videos.sort((a, b) { + var d = by == 'date' ? dates[a]!.compareTo(dates[b]!) : 0; + if (d == 0) d = naturalCompare(a.fileName.toLowerCase(), b.fileName.toLowerCase()); + return sortAscending ? d : -d; + }); + _saveVideoList(); + notifyListeners(); + } + Future updateSettings(void Function(AppSettings s) change) async { change(settings); _applySettings(); @@ -296,6 +345,52 @@ class AppController extends ChangeNotifier { notifyListeners(); } + // ───────── 여러 개 선택 (일괄 작업) ───────── + + /// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상) + final Set checked = {}; + + void toggleChecked(VideoItem v) { + if (!checked.remove(v)) checked.add(v); + notifyListeners(); + } + + /// 모두 체크되어 있으면 모두 해제, 아니면 모두 체크 + void toggleAllChecked() { + if (checked.length == videos.length) { + checked.clear(); + } else { + checked + ..clear() + ..addAll(videos); + } + notifyListeners(); + } + + /// 일괄 작업 대상: 체크한 동영상 (목록 순서대로). 체크한 것이 없으면 지금 보고 있는 한 개. + List get batchTargets { + checked.retainAll(videos); + if (checked.isNotEmpty) return videos.where(checked.contains).toList(); + return [?selected]; + } + + /// 체크한 동영상을 목록에서 제거 (작업 중인 것은 남긴다) + void removeChecked() { + final gone = checked.where((v) => v.status != JobStatus.running).toList(); + videos.removeWhere(gone.contains); + checked.removeAll(gone); + if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull; + _saveVideoList(); + notifyListeners(); + } + + /// [targets] 의 AI 자막을 만들고, 이어서 그 동영상들만 MKV 로 만든다 (자막 만들기를 취소하면 거기서 멈춤) + Future aiThenBuild(List targets, AiOptions opts) async { + await generateAiSubtitles(targets, opts); + if (_aiCancelled) return; + await buildVideos(targets); + } + /// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수. Future addDownloaded(List files) async { final before = videos.length; @@ -310,6 +405,7 @@ class AppController extends ChangeNotifier { void removeVideo(VideoItem v) { if (v.status == JobStatus.running) return; videos.remove(v); + checked.remove(v); if (selected == v) selected = videos.isEmpty ? null : videos.first; _saveVideoList(); notifyListeners(); @@ -318,6 +414,7 @@ class AppController extends ChangeNotifier { void clearVideos() { if (busy) return; videos.clear(); + checked.clear(); selected = null; _saveVideoList(); notifyListeners(); @@ -864,7 +961,8 @@ class AppController extends ChangeNotifier { /// 재생 준비. 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null, /// 아니면 내장 플레이어용 (목록, 시작 위치). - Future<(List, int)?> preparePlayback(List files) async { + /// [keepOrder]: 여러 개를 줄 때 이름순으로 다시 정렬하지 않고 준 순서대로 재생 + Future<(List, int)?> preparePlayback(List files, {bool keepOrder = false}) async { final videos = files.where(isVideoFile).toList(); if (videos.isEmpty) return null; final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); @@ -875,7 +973,7 @@ class AppController extends ChangeNotifier { return null; } if (videos.length > 1) { - videos.sort((a, b) => naturalCompare(p.basename(a), p.basename(b))); + if (!keepOrder) videos.sort((a, b) => naturalCompare(p.basename(a), p.basename(b))); return (videos, 0); } final siblings = await services.storage.listFiles(p.dirname(videos.first)); @@ -905,11 +1003,20 @@ class AppController extends ChangeNotifier { return _enqueue('MKV 만들기', _runBuildAll); } - Future _runBuildAll() async { + /// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로) + Future buildVideos(List targets) async { + if (ffmpegVersion == null || targets.isEmpty) return; + final label = targets.length == 1 ? 'MKV 만들기: ${targets.first.fileName}' : 'MKV 만들기 ${targets.length}개'; + return _enqueue(label, () => _runBuildAll(targets)); + } + + Future _runBuildAll([List? only]) async { _buildCancelled = false; try { // 동시에 [maxParallelJobs] 개씩 (0 = 무제한). 작업자가 목록에서 하나씩 가져가 처리한다. - final queue = videos.where((v) => v.status != JobStatus.done).toList(); + final queue = only?.where(videos.contains).toList() ?? + videos.where((v) => v.status != JobStatus.done).toList(); + if (queue.isEmpty) return; final limit = settings.maxParallelJobs <= 0 ? queue.length : settings.maxParallelJobs; if (queue.length > 1) _log('MKV 만들기: ${queue.length}개, 동시에 ${limit.clamp(1, queue.length)}개씩'); var next = 0; diff --git a/lib/app/download_manager.dart b/lib/app/download_manager.dart index 4e25c3c..2aa9fd9 100644 --- a/lib/app/download_manager.dart +++ b/lib/app/download_manager.dart @@ -34,6 +34,28 @@ class DownloadManager extends ChangeNotifier { Timer? _clipTimer; String? _lastClip; + /// 받은 동영상을 편집 목록(MKV 만들기)에 넣는 함수 (새로 넣은 개수를 돌려줌). 앱이 연결해 준다. + Future Function(List files)? addToEditList; + + /// "완료시 자동 동영상추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다. + Future Function(bool on)? setAutoAdd; + + /// 설정이 바뀌었을 때 화면 갱신 + void refresh() => notifyListeners(); + + /// 목록에서만 뺀다 (받은 파일은 그대로) + void dropFromList(DownloadTask t) { + tasks.remove(t); + selected.remove(t.id); + notifyListeners(); + } + + /// 고른 다운로드 중 다 받은 것의 동영상 파일 + List get selectedVideoFiles => [ + for (final t in _sel) + if (t.state == DownloadState.done) ...videoFilesOf(t), + ]; + /// 새로 추가된 다운로드 알림 (화면에 안내 표시용) final _added = StreamController.broadcast(); Stream get onAdded => _added.stream; @@ -180,7 +202,7 @@ class DownloadManager extends ChangeNotifier { holder ..expanding = false ..state = DownloadState.failed - ..error = '$e'; + ..error = e is ProcessException ? e.message : '$e'; notifyListeners(); } } diff --git a/lib/core/app_update.dart b/lib/core/app_update.dart index ee11148..88b3287 100644 --- a/lib/core/app_update.dart +++ b/lib/core/app_update.dart @@ -1,10 +1,22 @@ /// GitHub 저장소 (업데이트 확인) const updateRepo = 'RyuJongJin/JJ_MKVMaker'; -/// "v1.2.3" · "1.2.3+4" → [1, 2, 3] (숫자가 아닌 부분 무시) +/// 버전 글 → 숫자 목록. 버전은 "년.월.일_순번" (예: 2026.09.30_001) 이고, +/// 같은 날 다시 내면 순번이 늘고 날이 바뀌면 001 부터 다시 시작한다. +/// "v2026.09.30_001" · "2026.9.30+1" → [2026, 9, 30, 1] +/// 예전 방식 "v1.2.3" → [1, 2, 3] (년도가 훨씬 크므로 새 방식이 항상 더 최신) List parseVersion(String v) { - final core = v.trim().replaceFirst(RegExp(r'^[vV]'), '').split(RegExp(r'[+\-\s]')).first; - return [for (final part in core.split('.')) int.tryParse(part) ?? 0]; + final core = v.trim().replaceFirst(RegExp(r'^[vV]'), '').split(RegExp(r'[\-\s]')).first; + return [for (final part in core.split(RegExp(r'[._+]'))) int.tryParse(part) ?? 0]; +} + +/// 화면 · 태그에 쓰는 글: [2026, 9, 30, 1] → "2026.09.30_001", 예전 방식은 "1.2.3" +String formatVersion(String v) { + final n = parseVersion(v); + if (n.length < 3 || n[0] < 2000) return n.join('.'); + String two(int x) => x.toString().padLeft(2, '0'); + final seq = n.length > 3 ? n[3] : 1; + return '${n[0]}.${two(n[1])}.${two(n[2])}_${seq.toString().padLeft(3, '0')}'; } /// a < b 면 음수, 같으면 0, a > b 면 양수 @@ -19,8 +31,8 @@ int compareVersions(String a, String b) { /// GitHub Release 정보 class ReleaseInfo { - final String version; // 1.0.1 - final String tag; // v1.0.1 + final String version; // 2026.09.30_001 + final String tag; // v2026.09.30_001 final String name; final String notes; final String pageUrl; @@ -69,7 +81,7 @@ ReleaseInfo? parseLatestRelease(Map j) { sha = RegExp(r'\b([0-9a-fA-F]{64})\b').firstMatch(j['body'] as String? ?? '')?.group(1)?.toLowerCase(); } return ReleaseInfo( - version: parseVersion(tag).join('.'), + version: formatVersion(tag), tag: tag, name: j['name'] as String? ?? tag, notes: j['body'] as String? ?? '', diff --git a/lib/main.dart b/lib/main.dart index 51f859c..6f53197 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'app/app_controller.dart'; import 'app/bookmarks_controller.dart'; import 'app/download_manager.dart'; import 'app/settings.dart'; +import 'core/app_update.dart'; import 'core/playlist.dart'; import 'platform/windows/desktop_shell.dart'; import 'platform/windows/exit_trace.dart'; @@ -18,6 +19,7 @@ import 'platform/windows/single_instance.dart'; import 'platform/windows/window_memory.dart'; import 'services/platform_services.dart'; import 'ui/ai_dialog.dart'; +import 'ui/app_actions.dart'; import 'ui/browser_page.dart'; import 'ui/downloads_page.dart'; import 'ui/exit_dialog.dart'; @@ -33,7 +35,8 @@ String appTitle = 'JJ_MKVMaker'; /// 버전을 읽어 제목을 정하고, 설정 폴더를 돌려준다 Future _prepare() async { try { - appTitle = 'JJ_MKVMaker v${(await PackageInfo.fromPlatform()).version}'; + final info = await PackageInfo.fromPlatform(); + appTitle = 'JJ_MKVMaker v${formatVersion('${info.version}+${info.buildNumber}')}'; } catch (_) {} return (await getApplicationSupportDirectory()).path; } @@ -166,10 +169,19 @@ Future main(List args) async { downloads.onFinished.listen((t) async { if (!controller.settings.addFinishedDownloads) return; final files = DownloadManager.videoFilesOf(t); - if (files.isNotEmpty && await controller.addDownloaded(files) > 0) { - messengerKey.currentState?.showSnackBar(SnackBar(content: Text('편집 목록에 추가: ${t.title}'))); + if (files.isEmpty) return; + final n = await controller.addDownloaded(files); + // MKV 만들기 목록으로 넘어갔으므로 다운로드 목록에서는 뺀다 (받은 파일은 그대로) + downloads.dropFromList(t); + if (n > 0) { + messengerKey.currentState?.showSnackBar(SnackBar(content: Text('동영상 목록에 추가: ${t.title}'))); } }); + downloads.setAutoAdd = (on) async { + await controller.updateSettings((x) => x.addFinishedDownloads = on); + downloads.refresh(); + }; + downloads.addToEditList = controller.addDownloaded; unawaited(downloads.startClipboardWatch()); // 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리 @@ -270,7 +282,7 @@ Future runSecondWindow(List files, String dataDir) async { await controller.init(); controller.settings = await SettingsStore().load(); final navigatorKey = GlobalKey(); - runApp(JjCapCutApp(controller: controller, navigatorKey: navigatorKey)); + runApp(JjCapCutApp(controller: controller, navigatorKey: navigatorKey, onExit: () => exit(0))); WidgetsBinding.instance.addPostFrameCallback((_) async { await controller.shareVideoList(p.join(dataDir, 'videos.json')); unawaited(controller.addVideos(files, allowOutputFolder: true)); @@ -304,6 +316,7 @@ class JjCapCutApp extends StatelessWidget { theme: buildTheme(), navigatorKey: navigatorKey, scaffoldMessengerKey: messengerKey, + builder: (context, child) => AppScope(controller: controller, onExit: onExit, child: child!), home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks), ); } diff --git a/lib/platform/windows/github_updater.dart b/lib/platform/windows/github_updater.dart index 4000e16..c06b098 100644 --- a/lib/platform/windows/github_updater.dart +++ b/lib/platform/windows/github_updater.dart @@ -27,8 +27,12 @@ class GitHubUpdater implements Updater { }) : appDir = appDir ?? p.dirname(Platform.resolvedExecutable); @override - Future currentVersion() async => - versionOverride ?? (await PackageInfo.fromPlatform()).version; + Future currentVersion() async { + if (versionOverride != null) return versionOverride!; + // pubspec 의 "2026.9.30+1" → "2026.09.30_001" + final info = await PackageInfo.fromPlatform(); + return formatVersion('${info.version}+${info.buildNumber}'); + } @override Future latest() async { diff --git a/lib/platform/windows/windows_usage.dart b/lib/platform/windows/windows_usage.dart new file mode 100644 index 0000000..a9b49b3 --- /dev/null +++ b/lib/platform/windows/windows_usage.dart @@ -0,0 +1,77 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +import '../../services/system_usage.dart'; + +typedef _GetSystemTimesC = Int32 Function(Pointer idle, Pointer kernel, Pointer user); +typedef _GetSystemTimesD = int Function(Pointer idle, Pointer kernel, Pointer user); +typedef _MemStatusC = Int32 Function(Pointer buffer); +typedef _MemStatusD = int Function(Pointer buffer); +typedef _DiskFreeC = Int32 Function( + Pointer dir, Pointer freeForUser, Pointer total, Pointer free); +typedef _DiskFreeD = int Function( + Pointer dir, Pointer freeForUser, Pointer total, Pointer free); + +/// Windows: PC 전체 CPU 사용률 (GetSystemTimes 의 앞뒤 차이) · 물리 메모리 (GlobalMemoryStatusEx) +class WindowsUsage implements SystemUsage { + final _GetSystemTimesD _times; + final _MemStatusD _mem; + final _DiskFreeD _disk; + int? _idle, _total; + + WindowsUsage._(this._times, this._mem, this._disk); + + factory WindowsUsage() { + final k = DynamicLibrary.open('kernel32.dll'); + return WindowsUsage._( + k.lookupFunction<_GetSystemTimesC, _GetSystemTimesD>('GetSystemTimes'), + k.lookupFunction<_MemStatusC, _MemStatusD>('GlobalMemoryStatusEx'), + k.lookupFunction<_DiskFreeC, _DiskFreeD>('GetDiskFreeSpaceExW'), + ); + } + + /// 이 사용자가 쓸 수 있는 남은 용량 · 디스크 전체 용량 (GetDiskFreeSpaceEx) + @override + (int, int)? disk(String path) { + final dir = path.toNativeUtf16(); + final n = calloc(3); + try { + if (_disk(dir, n, n + 1, n + 2) == 0) return null; + return (n[0], n[1]); + } finally { + calloc + ..free(dir) + ..free(n); + } + } + + @override + UsageSample? read() { + // FILETIME (32비트 2개) 은 64비트 정수 하나와 같은 모양 + final t = calloc(3); + // MEMORYSTATUSEX: 길이(4) · 사용률(4) · 전체 물리(8) · 남은 물리(8) · … 모두 64바이트 + final m = calloc(64); + try { + if (_times(t, t + 1, t + 2) == 0) return null; + final idle = t[0]; + final total = t[1] + t[2]; // 커널 시간에 쉬는 시간이 들어 있다 + final prevIdle = _idle, prevTotal = _total; + _idle = idle; + _total = total; + + m.cast().value = 64; + if (_mem(m) == 0) return null; + final memTotal = (m + 8).cast().value; + final memFree = (m + 16).cast().value; + + if (prevIdle == null || prevTotal == null || total <= prevTotal) return null; + final cpu = 1 - (idle - prevIdle) / (total - prevTotal); + return UsageSample(cpu: cpu.clamp(0.0, 1.0), memUsed: memTotal - memFree, memTotal: memTotal); + } finally { + calloc + ..free(t) + ..free(m); + } + } +} diff --git a/lib/platform/windows/ytdlp_backend.dart b/lib/platform/windows/ytdlp_backend.dart index d1c33b6..2264d4e 100644 --- a/lib/platform/windows/ytdlp_backend.dart +++ b/lib/platform/windows/ytdlp_backend.dart @@ -69,18 +69,28 @@ class YtDlpBackend implements DownloadBackend { final listId = youtubePlaylistId(url); if (listId == null) return null; // 영상은 받지 않고 목록만 빠르게 읽는다 - final r = await Process.run( - ytdlp, - [ - '--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8', - if (deno != null) ...['--js-runtimes', 'deno:$deno'], - ...cookieArgs(), - 'https://www.youtube.com/playlist?list=$listId', - ], - environment: _env, - stdoutEncoding: utf8, - stderrEncoding: utf8, - ); + Future read(List cookies) => Process.run( + ytdlp, + [ + '--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8', + if (deno != null) ...['--js-runtimes', 'deno:$deno'], + ...cookies, + 'https://www.youtube.com/playlist?list=$listId', + ], + environment: _env, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + // 고른 브라우저의 쿠키를 못 읽으면: 앱 안 브라우저의 쿠키 → 쿠키 없이 (영상 받기와 같은 순서) + final tries = >[]; + for (final c in [cookieArgs(), fallbackCookieArgs(), const []]) { + if (!tries.any((t) => t.join(' ') == c.join(' '))) tries.add(c); + } + late ProcessResult r; + for (final cookies in tries) { + r = await read(cookies); + if (r.exitCode == 0 || !isCookieReadError(r.stderr as String)) break; + } if (r.exitCode != 0) { throw ProcessException( ytdlp, [url], '재생목록을 읽을 수 없습니다: ${friendlyYtDlpError((r.stderr as String).trim())}', r.exitCode); diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index f65e5ca..0f93411 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -14,6 +14,7 @@ import '../platform/windows/desktop_shell.dart'; import '../platform/windows/desktop_storage_service.dart'; import '../platform/windows/github_updater.dart'; import '../platform/windows/process_media_tool.dart'; +import '../platform/windows/windows_usage.dart'; import '../platform/windows/ytdlp_backend.dart'; import 'ai_services.dart'; import 'app_shell.dart'; @@ -24,6 +25,7 @@ import 'model_store.dart'; import 'preview_player.dart'; import 'storage_service.dart'; import 'subtitle_provider.dart'; +import 'system_usage.dart'; import 'updater.dart'; /// 플랫폼별 구현을 한 곳에서 고른다. @@ -52,6 +54,9 @@ class PlatformServices { /// 새 버전 확인 · 설치 (없으면 업데이트 기능 숨김) final Updater? updater; + /// PC 의 CPU · 메모리 사용량 (없으면 표시 숨김) + final SystemUsage? usage; + /// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다) final List Function(AppSettings Function() settings)? createSubtitleProviders; @@ -65,6 +70,7 @@ class PlatformServices { this.createDownloadBackends, this.createSubtitleProviders, this.updater, + this.usage, ModelStore? models, AppShell? shell, }) : models = models ?? ModelStore(), @@ -99,6 +105,7 @@ class PlatformServices { ], shell: DesktopShell(), updater: GitHubUpdater(), + usage: Platform.isWindows ? WindowsUsage() : null, createSubtitleProviders: (s) => [ OpenSubtitlesProvider( apiKey: () => s().openSubtitlesKey, diff --git a/lib/services/system_usage.dart b/lib/services/system_usage.dart new file mode 100644 index 0000000..1d67bca --- /dev/null +++ b/lib/services/system_usage.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// PC 전체의 CPU · 메모리 사용량 (한 번 잰 값) +class UsageSample { + /// 0.0 ~ 1.0 + final double cpu; + final int memUsed, memTotal; + + /// 다운로드 대상 디스크의 남은 용량 · 전체 용량 (바이트, 알 수 없으면 null) 과 그 드라이브 (예: "M:") + final int? diskFree, diskTotal; + final String disk; + const UsageSample({ + required this.cpu, + required this.memUsed, + required this.memTotal, + this.diskFree, + this.diskTotal, + this.disk = '', + }); + + double get mem => memTotal <= 0 ? 0 : memUsed / memTotal; + + /// 디스크가 거의 찼는지: 남은 용량이 5% 미만이거나 10GB 미만 + bool get diskLow => + diskFree != null && + (diskFree! < 10 * 1024 * 1024 * 1024 || (diskTotal != null && diskTotal! > 0 && diskFree! / diskTotal! < 0.05)); +} + +/// 사용량을 읽는 경계. +/// Windows: platform/windows/windows_usage.dart · 그 밖: 없음 (표시 숨김) +abstract class SystemUsage { + /// 지난번 호출 이후의 CPU 사용률과 지금 메모리. 아직 알 수 없으면 (첫 호출) null. + UsageSample? read(); + + /// [path] 가 있는 디스크의 (남은 용량, 전체 용량). 알 수 없으면 null. + (int, int)? disk(String path); +} + +/// 일정 간격으로 사용량을 읽어 알려 준다 (위쪽 막대의 CPU · MEM 표시) +class UsageMonitor extends ValueNotifier { + final SystemUsage source; + final Duration interval; + + /// 용량을 볼 디스크의 경로 (다운로드 폴더가 있는 드라이브, 예: "M:\\") + final String Function()? diskPath; + Timer? _timer; + + UsageMonitor(this.source, {this.interval = const Duration(seconds: 2), this.diskPath}) : super(null); + + // 보는 화면이 있을 때만 잰다 + @override + void addListener(VoidCallback listener) { + super.addListener(listener); + if (_timer == null) { + _tick(); + _timer = Timer.periodic(interval, (_) => _tick()); + } + } + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!hasListeners) { + _timer?.cancel(); + _timer = null; + } + } + + void _tick() { + try { + final s = source.read(); + if (s == null) return; + final path = diskPath?.call() ?? ''; + final d = path.isEmpty ? null : source.disk(path); + value = UsageSample( + cpu: s.cpu, + memUsed: s.memUsed, + memTotal: s.memTotal, + diskFree: d?.$1, + diskTotal: d?.$2, + disk: path.replaceAll(RegExp(r'[\\/]+$'), ''), + ); + } catch (_) {} + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index 4f4f359..0fb9d67 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -7,28 +7,36 @@ import '../services/model_store.dart'; import 'theme.dart'; /// AI 자막 만들기 설정 창 -/// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴) +/// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴 · 여러 개 선택) +/// [thenBuild]: 자막을 만든 뒤 그 동영상들을 이어서 MKV 로 만든다 Future showAiDialog(BuildContext context, AppController c, VideoItem current, - {List? targets}) async { + {List? targets, bool thenBuild = false}) async { final list = targets ?? [current]; + Future run(List v, AiOptions o) => + thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o); // 환경 설정에서 "매번 묻기" 를 끄면 마지막 설정으로 바로 시작 if (!c.settings.askAiOptions) { - await c.generateAiSubtitles(list, c.aiOptions); + await run(list, c.aiOptions); return; } final result = await showDialog<(AiOptions, bool)>( context: context, - builder: (_) => _AiDialog(c: c, videoCount: targets == null ? c.videos.length : 1), + builder: (_) => _AiDialog( + c: c, videoCount: targets == null ? c.videos.length : 1, targetCount: list.length, thenBuild: thenBuild), ); if (result == null) return; final (opts, all) = result; - await c.generateAiSubtitles(all ? List.of(c.videos) : list, opts); + await run(all ? List.of(c.videos) : list, opts); } class _AiDialog extends StatefulWidget { final AppController c; final int videoCount; - const _AiDialog({required this.c, required this.videoCount}); + + /// 이번에 자막을 만들 동영상 수 · 이어서 MKV 도 만드는지 (제목에 표시) + final int targetCount; + final bool thenBuild; + const _AiDialog({required this.c, required this.videoCount, this.targetCount = 1, this.thenBuild = false}); @override State<_AiDialog> createState() => _AiDialogState(); @@ -60,7 +68,8 @@ class _AiDialogState extends State<_AiDialog> { Widget build(BuildContext context) { final extra = languages.where((l) => !_o.targets.contains(l)).toList(); return AlertDialog( - title: const Text('AI 자막 만들기'), + title: Text('AI 자막 만들기${widget.thenBuild ? ' → MKV 만들기' : ''}' + '${widget.targetCount > 1 ? ' (동영상 ${widget.targetCount}개)' : ''}'), content: SizedBox( width: 520, child: SingleChildScrollView( diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart new file mode 100644 index 0000000..fa1328c --- /dev/null +++ b/lib/ui/app_actions.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../services/system_usage.dart'; +import 'settings_page.dart'; +import 'theme.dart'; + +/// 모든 화면의 위쪽 막대 높이 · 오른쪽 여백 (환경 설정 · 종료 버튼이 어느 화면에서나 같은 자리에 오도록) +const double appBarHeight = 56; +const double appBarRightPadding = 12; + +/// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다 +class AppScope extends InheritedWidget { + final AppController controller; + + /// 종료 버튼을 눌렀을 때 (없으면 버튼을 숨김) + final VoidCallback? onExit; + + const AppScope({super.key, required this.controller, this.onExit, required super.child}); + + static AppScope? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(AppScope old) => controller != old.controller || onExit != old.onExit; +} + +/// "CPU 23% MEM 61%" (90% 넘으면 빨갛게). 글자 폭이 바뀌어도 옆 버튼이 움직이지 않게 너비를 고정한다. +class UsageView extends StatelessWidget { + final UsageMonitor usage; + const UsageView({super.key, required this.usage}); + + static String _gb(int bytes) => (bytes / (1024 * 1024 * 1024)).toStringAsFixed(1); + + /// 남은 용량: "128GB" · "1.8TB" · "950MB" + static String _size(int bytes) { + const gb = 1024 * 1024 * 1024; + if (bytes >= 1000 * gb) return '${(bytes / (1024 * gb)).toStringAsFixed(1)}TB'; + if (bytes >= gb) return '${(bytes / gb).toStringAsFixed(bytes < 10 * gb ? 1 : 0)}GB'; + return '${(bytes / (1024 * 1024)).round()}MB'; + } + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: usage, + builder: (context, s, _) { + Widget cell(String label, String value, bool hot, double width) { + return SizedBox( + width: width, + child: Text.rich( + TextSpan(children: [ + TextSpan(text: '$label ', style: const TextStyle(color: JjColors.textDim)), + TextSpan( + text: value, + style: TextStyle( + color: hot ? JjColors.danger : JjColors.text, fontWeight: FontWeight.w600)), + ]), + maxLines: 1, + softWrap: false, + style: const TextStyle(fontSize: 11, fontFamily: 'Consolas'), + ), + ); + } + + Widget item(String label, double? v) => + cell(label, v == null ? '--%' : '${(v * 100).round()}%', v != null && v >= 0.9, 62); + final free = s?.diskFree; + + return Tooltip( + message: s == null + ? 'PC 전체 CPU · 메모리 사용량' + : 'PC 전체 사용량\nCPU ${(s.cpu * 100).round()}%\n' + '메모리 ${_gb(s.memUsed)} / ${_gb(s.memTotal)}GB (${(s.mem * 100).round()}%)' + '${free == null ? '' : '\n다운로드 디스크 ${s.disk} 남은 용량 ${_size(free)}' + '${s.diskTotal == null ? '' : ' / 전체 ${_size(s.diskTotal!)}'}'}', + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + item('CPU', s?.cpu), + item('MEM', s?.mem), + // 다운로드 대상 디스크의 남은 용량 + if (free != null) cell('DISK', _size(free), s!.diskLow, 84), + ]), + ), + ); + }, + ); +} + +/// 환경 설정 · 종료 버튼. 모든 화면의 위쪽 막대 맨 오른쪽에 같은 크기로 놓는다. +class AppActions extends StatelessWidget { + /// 직접 주지 않으면 [AppScope] 의 것을 쓴다 + final AppController? c; + final VoidCallback? onExit; + + /// 환경 설정 화면 자신: 설정 버튼을 "지금 여기" 로 표시하고 누를 수 없게 + final bool onSettingsPage; + + const AppActions({super.key, this.c, this.onExit, this.onSettingsPage = false}); + + @override + Widget build(BuildContext context) { + final scope = AppScope.maybeOf(context); + final controller = c ?? scope?.controller; + final exit = onExit ?? scope?.onExit; + if (controller == null && exit == null) return const SizedBox(); + final usage = controller?.usage; + return Row(mainAxisSize: MainAxisSize.min, children: [ + // CPU · MEM: 변환 · AI 작업이 PC 를 얼마나 쓰는지 (PC 전체 기준) + if (usage != null) UsageView(usage: usage), + const SizedBox(width: 4), + if (controller != null) + IconButton( + tooltip: '환경 설정', + icon: Icon(onSettingsPage ? Icons.settings : Icons.settings_outlined, + color: onSettingsPage ? JjColors.accent : null), + onPressed: onSettingsPage + ? null + : () => Navigator.push( + context, MaterialPageRoute(builder: (_) => SettingsPage(c: controller))), + ), + if (exit != null) + IconButton( + tooltip: '종료', + icon: const Icon(Icons.power_settings_new, color: JjColors.danger), + onPressed: exit, + ), + ]); + } +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 66c216b..4819049 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -10,6 +10,7 @@ import '../app/download_manager.dart'; import '../core/bookmarks.dart'; import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies; import '../core/web_address.dart'; +import 'app_actions.dart'; import 'downloads_page.dart'; import 'theme.dart'; import 'work_panel.dart'; @@ -179,7 +180,9 @@ class _BrowserPageState extends State { child: Scaffold( body: ListenableBuilder( listenable: Listenable.merge([bm, widget.c]), - builder: (context, _) => Row(children: [ + builder: (context, _) => Column(children: [ + _toolbar(), + Expanded(child: Row(children: [ if (_panel) SizedBox( width: 320, @@ -195,7 +198,6 @@ class _BrowserPageState extends State { if (_panel) const VerticalDivider(width: 1), Expanded( child: Column(children: [ - _toolbar(), _BookmarkBar(bm: bm, onOpen: _go, onOpenExternal: _openExternal), SizedBox( height: 2, @@ -222,6 +224,7 @@ class _BrowserPageState extends State { ), ), ], + ])), ]), ), ), @@ -238,9 +241,9 @@ class _BrowserPageState extends State { onPressed: f, ); return Container( - height: 48, + height: appBarHeight, color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 6), + padding: const EdgeInsets.only(left: 6, right: appBarRightPadding), child: Row(children: [ btn(Icons.video_library_outlined, 'MKV 화면으로', () => Navigator.maybePop(context), color: JjColors.accent), const SizedBox(width: 4), @@ -295,6 +298,7 @@ class _BrowserPageState extends State { _toggleWork, color: widget.c.busy ? JjColors.accent : null), btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, '즐겨찾기 관리 (Ctrl+Shift+B)', () => setState(() => _panel = !_panel)), + AppActions(c: widget.c), ]), ); } diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 4c4e4cc..6d8a1d3 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../app/download_manager.dart'; import '../core/download_detect.dart'; import '../services/downloader.dart'; +import 'app_actions.dart'; import 'theme.dart'; /// 다운로드 목록 @@ -45,9 +46,9 @@ class _DownloadsPageState extends State { return Scaffold( body: Column(children: [ Container( - height: 56, + height: appBarHeight, color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), child: Row(children: [ IconButton( tooltip: '돌아가기', @@ -71,6 +72,7 @@ class _DownloadsPageState extends State { ), const SizedBox(width: 8), FilledButton(onPressed: _add, child: const Text('추가')), + const AppActions(), ]), ), Container( @@ -94,6 +96,30 @@ class _DownloadsPageState extends State { ]), ), const Spacer(), + if (d.addToEditList != null) ...[ + _btn(Icons.playlist_add, '동영상 추가', + hasSel && d.selectedVideoFiles.isNotEmpty ? () => _addToEditList(context) : null), + // 켜면: 다 받는 대로 MKV 만들기 목록에 넣고 이 목록에서는 뺀다 + if (d.setAutoAdd != null) + Tooltip( + message: '다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)', + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => d.setAutoAdd!(!d.settings().addFinishedDownloads), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Checkbox( + visualDensity: VisualDensity.compact, + value: d.settings().addFinishedDownloads, + onChanged: (v) => d.setAutoAdd!(v ?? false), + ), + const Padding( + padding: EdgeInsets.only(right: 8), + child: Text('완료시 자동 동영상추가', style: TextStyle(fontSize: 12)), + ), + ]), + ), + ), + ], _btn(Icons.pause, '일시정지', hasSel ? d.pauseSelected : null), _btn(Icons.play_arrow, '재개', hasSel ? d.resumeSelected : null), _btn(Icons.stop, '취소', hasSel ? d.cancelSelected : null), @@ -129,6 +155,17 @@ class _DownloadsPageState extends State { ), ); + /// 고른 (다 받은) 동영상을 MKV 만들기의 동영상 목록에 추가 + Future _addToEditList(BuildContext context) async { + final files = widget.d.selectedVideoFiles; + final m = ScaffoldMessenger.of(context); + final n = await widget.d.addToEditList!(files); + m.showSnackBar(SnackBar( + content: Text(n > 0 + ? 'MKV 만들기 목록에 동영상 $n개를 추가했습니다.' + : '이미 목록에 있는 동영상입니다 (${files.length}개).'))); + } + Future _confirmRemove(BuildContext context) async { final n = widget.d.selected.length; final ok = await showDialog( diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index c452097..7e33690 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -13,10 +13,10 @@ import '../core/models.dart'; import '../core/output_paths.dart'; import '../core/playlist.dart'; import 'ai_dialog.dart'; +import 'app_actions.dart'; import 'browser_page.dart'; import 'downloads_page.dart'; import 'player_page.dart'; -import 'settings_page.dart'; import 'subtitle_editor_page.dart'; import 'subtitle_search_dialog.dart'; import 'theme.dart'; @@ -108,13 +108,21 @@ class _TopBar extends StatelessWidget { final BookmarksController? bookmarks; const _TopBar({required this.c, this.downloads, this.onExit, this.bookmarks}); + static String _batchHint(AppController c, String what) => c.checked.isEmpty + ? '지금 보고 있는 동영상의 $what (여러 개는 목록에서 체크)' + : '체크한 동영상 ${c.checked.length}개의 $what'; + @override Widget build(BuildContext context) { final canBuild = c.videos.isNotEmpty && c.ffmpegVersion != null; + // 일괄 작업 대상: 체크한 동영상, 없으면 지금 보고 있는 한 개 + final batch = c.batchTargets; + final canBatch = batch.isNotEmpty && c.ffmpegVersion != null; + final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; return Container( - height: 56, + height: appBarHeight, color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.only(left: 16, right: appBarRightPadding), child: Row( children: [ // 프로그램 아이콘 (tool/make_icon.py 로 생성) @@ -123,18 +131,42 @@ class _TopBar extends StatelessWidget { const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), const SizedBox(width: 24), - OutlinedButton.icon( - onPressed: c.pickVideos, - icon: const Icon(Icons.add, size: 18), - label: const Text('동영상 추가'), + // 왼쪽 묶음: 창이 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 버튼이 밀려나지 않도록) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + OutlinedButton.icon( + onPressed: c.pickVideos, + icon: const Icon(Icons.add, size: 18), + label: const Text('동영상 추가'), + ), + if (c.aiAvailable) ...[ + const SizedBox(width: 8), + Tooltip( + message: _batchHint(c, 'AI 자막을 만듭니다'), + child: OutlinedButton.icon( + onPressed: canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null, + icon: const Icon(Icons.auto_awesome, size: 18), + label: Text('자막 만들기$count'), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: _batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다'), + child: OutlinedButton.icon( + onPressed: canBatch + ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) + : null, + icon: const Icon(Icons.auto_mode, size: 18), + label: Text('자막 만들기 & MKV 만들기$count'), + ), + ), + ], + ]), + ), ), const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: c.busy || c.videos.isEmpty ? null : c.clearVideos, - icon: const Icon(Icons.clear_all, size: 18), - label: const Text('모두 지우기'), - ), - const Spacer(), if (c.busy) ...[ JobIndicator(c: c), const SizedBox(width: 8), @@ -145,10 +177,27 @@ class _TopBar extends StatelessWidget { ), const SizedBox(width: 8), ], + // 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개) + if (c.services.createMediaPlayer != null) ...[ + Tooltip( + message: c.checked.isEmpty + ? '지금 보고 있는 동영상을 재생합니다 (여러 개는 목록에서 체크)' + : '체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서 재생합니다', + child: OutlinedButton.icon( + onPressed: batch.isEmpty + ? null + : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), + icon: const Icon(Icons.playlist_play, size: 20), + label: Text('선택한 파일 재생$count'), + ), + ), + const SizedBox(width: 8), + ], FilledButton.icon( - onPressed: canBuild ? c.buildAll : null, + // 체크한 동영상이 있으면 그것만, 없으면 목록 전체 + onPressed: !canBuild ? null : (c.checked.isEmpty ? c.buildAll : () => c.buildVideos(batch)), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), - label: Text(c.busy ? 'MKV 만들기 (대기열)' : 'MKV 만들기'), + label: Text('MKV 만들기$count${c.busy ? ' (대기열)' : ''}'), ), if (bookmarks != null) ...[ const SizedBox(width: 8), @@ -165,19 +214,8 @@ class _TopBar extends StatelessWidget { const SizedBox(width: 12), _DownloadBox(d: downloads!), ], - const SizedBox(width: 4), - IconButton( - tooltip: '환경 설정', - icon: const Icon(Icons.settings_outlined), - onPressed: () => Navigator.push( - context, MaterialPageRoute(builder: (_) => SettingsPage(c: c))), - ), - if (onExit != null) - IconButton( - tooltip: '종료', - icon: const Icon(Icons.power_settings_new, color: JjColors.danger), - onPressed: onExit, - ), + // 환경 설정 · 종료: 모든 화면에서 같은 자리 + AppActions(c: c, onExit: onExit), ], ), ); @@ -313,6 +351,23 @@ class _VideoList extends StatelessWidget { final AppController c; const _VideoList({required this.c}); + /// 정렬 아이콘: 지금 적용된 정렬은 강조색 + 방향 화살표 + Widget _sortButton(String by, IconData icon, String name) { + final on = c.sortedBy == by; + return IconButton( + tooltip: '$name 순으로 정렬' + '${on ? (c.sortAscending ? ' (지금: 오름차순 · 다시 누르면 내림차순)' : ' (지금: 내림차순 · 다시 누르면 오름차순)') : ''}', + iconSize: 18, + visualDensity: VisualDensity.compact, + onPressed: c.videos.length < 2 ? null : () => c.sortVideos(by), + icon: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, color: on ? JjColors.accent : JjColors.textDim), + if (on) + Icon(c.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 12, color: JjColors.accent), + ]), + ); + } + @override Widget build(BuildContext context) { return Container( @@ -320,10 +375,47 @@ class _VideoList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 전체 선택 · 선택 개수 · 선택 제거 · 모두 지우기 Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Text('동영상 ${c.videos.length}개', - style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + padding: const EdgeInsets.fromLTRB(8, 6, 4, 2), + child: Row(children: [ + Checkbox( + visualDensity: VisualDensity.compact, + tristate: true, + value: c.videos.isEmpty || c.checked.isEmpty + ? false + : (c.checked.length == c.videos.length ? true : null), + onChanged: c.videos.isEmpty ? null : (_) => c.toggleAllChecked(), + ), + Expanded( + child: InkWell( + onTap: c.videos.isEmpty ? null : c.toggleAllChecked, + child: Text( + c.checked.isEmpty + ? '동영상 ${c.videos.length}개 · 전체 선택' + : '선택 ${c.checked.length} / ${c.videos.length}개', + style: const TextStyle(color: JjColors.textDim, fontSize: 12)), + ), + ), + // 정렬: 같은 것을 다시 누르면 반대 순서 + _sortButton('name', Icons.sort_by_alpha, '파일 이름'), + _sortButton('date', Icons.calendar_month, '날짜'), + if (c.checked.isNotEmpty) + IconButton( + tooltip: '선택한 동영상을 목록에서 제거', + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.playlist_remove, color: JjColors.textDim), + onPressed: c.removeChecked, + ), + IconButton( + tooltip: '모두 지우기', + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.clear_all, color: JjColors.textDim), + onPressed: c.busy || c.videos.isEmpty ? null : c.clearVideos, + ), + ]), ), Expanded( child: ListView.builder( @@ -351,7 +443,7 @@ class _VideoTile extends StatelessWidget { onDoubleTap: () => playFiles(context, c, [v.path]), child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - padding: const EdgeInsets.fromLTRB(12, 10, 4, 10), + padding: const EdgeInsets.fromLTRB(2, 10, 4, 10), decoration: BoxDecoration( color: isSel ? JjColors.panelHigh : Colors.transparent, borderRadius: BorderRadius.circular(8), @@ -360,8 +452,14 @@ class _VideoTile extends StatelessWidget { ), child: Row( children: [ + // 체크: 여러 개를 골라 일괄 작업 (줄을 누르면 지금처럼 한 개 보기) + Checkbox( + visualDensity: VisualDensity.compact, + value: c.checked.contains(v), + onChanged: (_) => c.toggleChecked(v), + ), _StatusIcon(v.status), - const SizedBox(width: 10), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 721a21e..b562d82 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -11,12 +11,14 @@ import '../app/app_controller.dart'; import '../core/playlist.dart'; import '../core/srt.dart' show formatSrtTime; import '../services/media_player.dart'; +import 'app_actions.dart'; import 'theme.dart'; /// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로) -Future playFiles(BuildContext context, AppController c, List files) async { +Future playFiles(BuildContext context, AppController c, List files, + {bool keepOrder = false}) async { final create = c.services.createMediaPlayer; - final plan = await c.preparePlayback(files); + final plan = await c.preparePlayback(files, keepOrder: keepOrder); if (plan == null || create == null || !context.mounted) return; final (list, start) = plan; await Navigator.of(context).push(MaterialPageRoute( @@ -246,9 +248,9 @@ class _PlayerPageState extends State { } Widget _header() => Container( - height: 44, + height: appBarHeight, color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.only(left: 4, right: appBarRightPadding), child: Row(children: [ IconButton( tooltip: '돌아가기', @@ -265,6 +267,7 @@ class _PlayerPageState extends State { ), ), ), + AppActions(c: c), ]), ); diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 73c9ac0..c38e64e 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -12,6 +12,7 @@ import '../core/subtitle_detector.dart'; import '../services/app_shell.dart'; import 'setup_dialog.dart'; import 'update_dialog.dart'; +import 'app_actions.dart'; import 'theme.dart'; /// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 @@ -239,9 +240,9 @@ class _SettingsPageState extends State { return Scaffold( body: Column(children: [ Container( - height: 56, + height: appBarHeight, color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), child: Row(children: [ IconButton( tooltip: '돌아가기', @@ -251,7 +252,8 @@ class _SettingsPageState extends State { const Text('환경 설정', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), const Text('바꾸면 바로 저장됩니다', style: TextStyle(fontSize: 12, color: JjColors.textDim)), - const SizedBox(width: 12), + const SizedBox(width: 8), + AppActions(c: c, onSettingsPage: true), ]), ), Expanded( @@ -507,8 +509,8 @@ class _SettingsPageState extends State { SwitchListTile( value: s.addFinishedDownloads, onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v), - title: const Text('다 받은 동영상을 편집 목록에 자동 추가'), - subtitle: const Text('브라우저에서 받은 영상도 바로 자막 만들기 · MKV 만들기를 할 수 있게 목록에 넣습니다'), + title: const Text('완료시 자동 동영상추가'), + subtitle: const Text('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)'), ), _section('AI 자막'), SwitchListTile( diff --git a/lib/ui/subtitle_editor_page.dart b/lib/ui/subtitle_editor_page.dart index 4a0d5ae..5f8724e 100644 --- a/lib/ui/subtitle_editor_page.dart +++ b/lib/ui/subtitle_editor_page.dart @@ -10,6 +10,7 @@ import '../core/models.dart'; import '../core/srt.dart'; import '../core/text_codec.dart'; import '../services/preview_player.dart'; +import 'app_actions.dart'; import 'theme.dart'; import 'timeline.dart'; @@ -471,9 +472,9 @@ class _SubtitleEditorPageState extends State { } Widget _toolbar(BuildContext context) => Container( - height: 56, + height: appBarHeight, color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), child: Row( children: [ IconButton( @@ -536,6 +537,7 @@ class _SubtitleEditorPageState extends State { icon: const Icon(Icons.save, size: 18), label: Text(e.dirty ? '저장 (MKV 반영) *' : '저장 (MKV 반영)'), ), + const AppActions(), ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 6b736c7..cc66489 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 2026.9.30+1 environment: sdk: ^3.13.4 diff --git a/test/batch_test.dart b/test/batch_test.dart new file mode 100644 index 0000000..30a9417 --- /dev/null +++ b/test/batch_test.dart @@ -0,0 +1,342 @@ +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/app/bookmarks_controller.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/core/models.dart'; +import 'package:jj_mkvmaker/core/output_paths.dart'; +import 'package:jj_mkvmaker/core/srt.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/ai_services.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/services/model_store.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/app_actions.dart'; +import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:path/path.dart' as p; + +class _Recognizer implements SpeechRecognizer { + final heard = []; + @override + Future> transcribe(String wavPath, + {required String modelPath, String language = 'auto', AiProgress? onProgress}) async { + heard.add(p.basename(wavPath)); + return [Cue(const Duration(seconds: 1), const Duration(seconds: 2), 'Hello everyone, this is a test.')]; + } +} + +class _Translator implements Translator { + @override + Future load(String modelDir) async {} + @override + Future> translate(List lines, + {required String source, required String target, AiProgress? onProgress}) async => + [for (final l in lines) '[$target] $l']; + @override + void cancel() {} + @override + Future dispose() async {} +} + +class _Backend implements DownloadBackend { + @override + DownloadKind get kind => DownloadKind.video; + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading; + @override + Future pause(DownloadTask t) async {} + @override + Future cancel(DownloadTask t) async {} + @override + Future shutdown() async {} +} + +AppController _plain() => + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + +void main() { + test('여러 개 선택: 체크 · 전체 선택 · 일괄 대상 · 선택 제거', () async { + final dir = Directory.systemTemp.createTempSync('jj_batch_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final files = [for (final n in ['a', 'b', 'c']) p.join(dir.path, '$n.mp4')]; + for (final f in files) { + File(f).writeAsStringSync('x'); + } + final c = _plain(); + await c.addVideos(files); + final [a, b, cc] = c.videos; + + // 체크한 것이 없으면 지금 보고 있는 한 개 + expect(c.batchTargets, [a]); + c.toggleChecked(cc); + c.toggleChecked(a); + expect(c.batchTargets, [a, cc]); // 목록 순서대로 + c.toggleAllChecked(); + expect(c.checked, hasLength(3)); + c.toggleAllChecked(); + expect(c.checked, isEmpty); + + c + ..toggleChecked(a) + ..toggleChecked(b); + b.status = JobStatus.running; // 작업 중인 것은 남긴다 + c.removeChecked(); + expect(c.videos, [b, cc]); + expect(c.selected, b); + b.status = JobStatus.ready; + c.removeVideo(b); + expect(c.checked, isEmpty); + expect(c.batchTargets, [cc]); + }); + + test('목록 정렬 (파일 이름 · 날짜, 다시 누르면 반대) · 선택한 파일을 목록 순서대로 재생', () async { + final dir = Directory.systemTemp.createTempSync('jj_sort_'); + addTearDown(() => dir.deleteSync(recursive: true)); + // 이름순: ep2 < ep10 < intro 날짜순: intro(가장 오래됨) < ep10 < ep2 + final when = {'ep10.mp4': 2, 'intro.mp4': 1, 'ep2.mp4': 3}; + for (final e in when.entries) { + File(p.join(dir.path, e.key)) + ..writeAsStringSync('x') + ..setLastModifiedSync(DateTime(2026, 1, e.value)); + } + final c = _plain(); + await c.addVideos([for (final n in when.keys) p.join(dir.path, n)]); + List names() => [for (final v in c.videos) v.fileName]; + expect(names(), ['ep10.mp4', 'intro.mp4', 'ep2.mp4']); + + c.sortVideos('name'); + expect(names(), ['ep2.mp4', 'ep10.mp4', 'intro.mp4']); // 숫자는 숫자로 비교 + c.sortVideos('name'); + expect(names(), ['intro.mp4', 'ep10.mp4', 'ep2.mp4']); + c.sortVideos('date'); + expect([c.sortedBy, c.sortAscending], ['date', true]); + expect(names(), ['intro.mp4', 'ep10.mp4', 'ep2.mp4']); + c.sortVideos('date'); + expect(names(), ['ep2.mp4', 'ep10.mp4', 'intro.mp4']); + + // 재생 순서: 지금 목록에 보이는 순서 그대로 (이름순으로 다시 정렬하지 않음) + c.sortVideos('date'); // intro, ep10, ep2 + for (final v in c.videos) { + c.toggleChecked(v); + } + final files = [for (final v in c.batchTargets) v.path]; + final (kept, start) = (await c.preparePlayback(files, keepOrder: true))!; + expect([for (final f in kept) p.basename(f)], ['intro.mp4', 'ep10.mp4', 'ep2.mp4']); + expect(start, 0); + final (sorted, _) = (await c.preparePlayback(files))!; + expect([for (final f in sorted) p.basename(f)], ['ep2.mp4', 'ep10.mp4', 'intro.mp4']); + }); + + test('자막 만들기 & MKV 만들기: 체크한 동영상만 자막 → MKV 까지 일괄', () async { + final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); + if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); + final dir = Directory.systemTemp.createTempSync('jj_batch2_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final files = [for (final n in ['one', 'two', 'three']) p.join(dir.path, '$n.mp4')]; + for (final f in files) { + await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=160x120:rate=10', + '-f', 'lavfi', '-i', 'sine', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', f]); + } + final models = ModelStore(p.join(dir.path, 'models')); + for (final m in [whisperModels.first, nllbModel]) { + for (var i = 0; i < m.files.length; i++) { + File(await models.pathOf(m, i)) + ..createSync(recursive: true) + ..writeAsStringSync('x'); + } + } + final rec = _Recognizer(); + final c = AppController(PlatformServices( + mediaTool: tool, + storage: DesktopStorageService(), + createRecognizer: () => rec, + createTranslator: _Translator.new, + models: models, + )); + await c.init(); + await c.addVideos(files); + final [one, two, three] = c.videos; + c + ..toggleChecked(one) + ..toggleChecked(three); + + await c.aiThenBuild(c.batchTargets, AiOptions.defaults()); + + expect(rec.heard, hasLength(2)); + expect([one.status, two.status, three.status], [JobStatus.done, JobStatus.ready, JobStatus.done], + reason: '${one.message} ${three.message}'); + expect(File(outputMkvPath(one.path)).existsSync(), isTrue); + expect(File(outputMkvPath(two.path)).existsSync(), isFalse); + final info = await tool.probe(outputMkvPath(three.path)); + expect(info.ofType('subtitle').map((s) => s.language), containsAll(['kor', 'eng', 'jpn'])); + expect(c.busy, isFalse); + + // 고른 것만 다시 만들기 (이미 만든 것도) + await c.buildVideos([one]); + expect(one.status, JobStatus.done); + }); + + testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: DesktopStorageService(), + createRecognizer: _Recognizer.new, + createTranslator: _Translator.new, + )); + for (final n in ['a', 'b', 'c']) { + c.videos.add(VideoItem('D:\\v\\$n.mp4')); + } + c.selected = c.videos.first; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + expect(find.text('자막 만들기'), findsOneWidget); + expect(find.text('자막 만들기 & MKV 만들기'), findsOneWidget); + expect(find.text('동영상 3개 · 전체 선택'), findsOneWidget); + + // 줄의 체크 상자 (0 번째는 전체 선택) + // (줄에 더블클릭 = 재생이 있어 탭은 더블클릭 대기 시간 뒤에 확정된다) + await tester.tap(find.byType(Checkbox).at(1)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.byType(Checkbox).at(3)); + await tester.pump(const Duration(milliseconds: 400)); + expect(c.checked, {c.videos[0], c.videos[2]}); + expect(find.text('선택 2 / 3개'), findsOneWidget); + expect(find.text('자막 만들기 (2)'), findsOneWidget); + expect(find.text('자막 만들기 & MKV 만들기 (2)'), findsOneWidget); + expect(find.text('MKV 만들기 (2)'), findsOneWidget); + expect(find.byTooltip(RegExp('^파일 이름 순으로 정렬')), findsOneWidget); + expect(find.byTooltip(RegExp('^날짜 순으로 정렬')), findsOneWidget); + + await tester.tap(find.text('선택 2 / 3개')); + await tester.pump(); + expect(c.checked, hasLength(3)); + await tester.tap(find.byTooltip('선택한 동영상을 목록에서 제거')); + await tester.pump(); + expect(c.videos, isEmpty); + }); + + testWidgets('다운로드 화면: 다 받은 것을 골라 [동영상 추가] → MKV 목록', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_dladd_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = p.join(dir.path, 'movie.mp4'); + File(file).writeAsStringSync('x'); + + final d = DownloadManager( + backends: [_Backend()], settings: () => AppSettings()..downloadRoot = dir.path, readClipboard: () async => null); + final added = []; + d.addToEditList = (files) async { + added.addAll(files); + return files.length; + }; + final done = d.addPage('https://vimeo.com/1')! + ..title = '다 받은 영상' + ..files.add(file) + ..state = DownloadState.done; + d.addPage('https://vimeo.com/2')!.title = '받는 중인 영상'; + + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + OutlinedButton btn() => tester.widget( + find.ancestor(of: find.text('동영상 추가'), matching: find.byWidgetPredicate((w) => w is OutlinedButton))); + expect(btn().onPressed, isNull); // 고른 것이 없음 + + await tester.tap(find.text('받는 중인 영상')); + await tester.pump(); + expect(btn().onPressed, isNull); // 아직 다 받지 않음 + + await tester.tap(find.text('다 받은 영상')); + await tester.pump(); + expect(d.selectedVideoFiles, [file]); + await tester.tap(find.text('동영상 추가')); + await tester.pump(); + expect(added, [file]); + expect(find.textContaining('동영상 1개를 추가했습니다'), findsOneWidget); + expect(done.state, DownloadState.done); + d.dispose(); + }); + + testWidgets('다운로드 화면: "완료시 자동 동영상추가" 체크 → 설정 변경, 목록에서 빼기', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final s = AppSettings()..addFinishedDownloads = false; + final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null); + d.addToEditList = (files) async => files.length; + d.setAutoAdd = (on) async { + s.addFinishedDownloads = on; + d.refresh(); + }; + final t = d.addPage('https://vimeo.com/1')!..title = '영상'; + + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + Checkbox box() => tester.widget( + find.descendant(of: find.byTooltip(RegExp('MKV 만들기의 동영상 목록에 자동으로')), matching: find.byType(Checkbox))); + expect(box().value, isFalse); + await tester.tap(find.text('완료시 자동 동영상추가')); + await tester.pump(); + expect(s.addFinishedDownloads, isTrue); + expect(box().value, isTrue); + + // 다 받으면 목록에서 뺀다 (앱이 onFinished 에서 부름) + d.dropFromList(t); + await tester.pump(); + expect(d.tasks, isEmpty); + expect(find.text('영상'), findsNothing); + d.dispose(); + }); + + testWidgets('환경 설정 · 종료 버튼: 모든 화면에서 같은 자리', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_pos_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final c = _plain(); + final d = DownloadManager(backends: [_Backend()], settings: () => c.settings, readClipboard: () async => null); + final bm = BookmarksController(p.join(dir.path, 'bm.json')); + var exits = 0; + + Future<(Offset, Offset)> at(Widget page) async { + await tester.pumpWidget(MaterialApp( + key: UniqueKey(), + builder: (context, child) => AppScope(controller: c, onExit: () => exits++, child: child!), + home: page, + )); + await tester.pump(); + return (tester.getCenter(find.byTooltip('환경 설정')), tester.getCenter(find.byTooltip('종료'))); + } + + final home = await at(HomePage(c: c, downloads: d, bookmarks: bm, onExit: () => exits++)); + final pages = { + '다운로드': await at(DownloadsPage(d: d)), + '환경 설정': await at(SettingsPage(c: c)), + '브라우저': await at(BrowserPage( + c: c, bookmarks: bm, downloads: d, viewBuilder: (h, url) => const ColoredBox(color: Colors.white))), + }; + for (final e in pages.entries) { + expect(e.value, home, reason: '${e.key} 화면의 버튼 자리가 MKV 화면과 다릅니다'); + } + // 오른쪽 위 구석 + expect(home.$2.dx, greaterThan(1500 - 60)); + expect(home.$2.dy, 28); + + // 브라우저에서 종료 버튼 → 앱의 종료 동작 + await tester.tap(find.byTooltip('종료')); + expect(exits, 1); + d.dispose(); + }); +} diff --git a/test/update_test.dart b/test/update_test.dart index c7e33de..d1e6e6d 100644 --- a/test/update_test.dart +++ b/test/update_test.dart @@ -34,6 +34,24 @@ void main() { expect(compareVersions('1.10.0', '1.9.9'), greaterThan(0)); expect(compareVersions('1.0.0+5', 'v1.0.1'), lessThan(0)); }); + test('버전: 년.월.일_순번', () { + expect(parseVersion('v2026.09.30_001'), [2026, 9, 30, 1]); + expect(formatVersion('2026.9.30+1'), '2026.09.30_001'); // pubspec 의 표기 → 보이는 글 + expect(formatVersion('v2026.09.30_012'), '2026.09.30_012'); + expect(formatVersion('2026.10.1+3'), '2026.10.01_003'); + expect(formatVersion('v1.2.3'), '1.2.3'); // 예전 방식은 그대로 + // 같은 날: 순번이 크면 최신. 날이 바뀌면 001 이어도 최신. + expect(compareVersions('2026.09.30_002', '2026.9.30+1'), greaterThan(0)); + expect(compareVersions('v2026.09.30_001', '2026.9.30+1'), 0); + expect(compareVersions('2026.10.01_001', '2026.09.30_015'), greaterThan(0)); + expect(compareVersions('2027.01.01_001', '2026.12.31_009'), greaterThan(0)); + // 예전 1.x 에서 쓰던 사람도 새 방식을 업데이트로 받는다 + expect(compareVersions('v2026.09.30_001', '1.1.1'), greaterThan(0)); + final r = parseLatestRelease(_release('v2026.09.30_002'))!; + expect(r.version, '2026.09.30_002'); + expect(r.isNewerThan('2026.09.30_001'), isTrue); + expect(r.isNewerThan('2026.09.30_002'), isFalse); + }); test('releases/latest 해석', () { final r = parseLatestRelease(_release('v1.2.0', digest: 'sha256:${'A' * 64}'))!; expect([r.version, r.tag, r.zipName, r.sha256], ['1.2.0', 'v1.2.0', 'JJ_MKVMaker_v1.2.0_win64.zip', 'a' * 64]); diff --git a/test/usage_test.dart b/test/usage_test.dart new file mode 100644 index 0000000..c0c3b4f --- /dev/null +++ b/test/usage_test.dart @@ -0,0 +1,94 @@ +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/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/platform/windows/windows_usage.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/services/system_usage.dart'; +import 'package:jj_mkvmaker/ui/app_actions.dart'; + +class _Fake implements SystemUsage { + UsageSample? next; + (int, int)? space; + @override + UsageSample? read() => next; + @override + (int, int)? disk(String path) => space; +} + +void main() { + test('Windows: 실제 CPU · 메모리 값 읽기', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용'); + final u = WindowsUsage(); + expect(u.read(), isNull); // 첫 호출은 기준점만 + // 조금 일하게 한 뒤 다시 + final end = DateTime.now().add(const Duration(milliseconds: 300)); + var x = 0; + while (DateTime.now().isBefore(end)) { + x += 1; + } + expect(x, greaterThan(0)); + final s = u.read()!; + // ignore: avoid_print + print('RESULT CPU ${(s.cpu * 100).toStringAsFixed(1)}% MEM ${(s.mem * 100).toStringAsFixed(1)}% ' + '(${s.memUsed ~/ (1 << 20)}MB / ${s.memTotal ~/ (1 << 20)}MB)'); + expect(s.cpu, inInclusiveRange(0.0, 1.0)); + expect(s.cpu, greaterThan(0)); // 방금 이 테스트가 CPU 를 썼다 + expect(s.memTotal, greaterThan(1 << 30)); // 1GB 이상 + expect(s.memUsed, inExclusiveRange(0, s.memTotal)); + + // 디스크: 이 테스트가 도는 드라이브 + final root = Directory.current.path.substring(0, 3); + final (free, total) = u.disk(root)!; + // ignore: avoid_print + print('RESULT DISK $root 남은 ${free ~/ (1 << 30)}GB / 전체 ${total ~/ (1 << 30)}GB'); + expect(total, greaterThan(1 << 30)); + expect(free, inInclusiveRange(0, total)); + expect(u.disk(r'?:\없는 드라이브'), isNull); + }); + + testWidgets('위쪽 막대: 환경 설정 앞에 CPU · MEM 표시, 값이 바뀌면 갱신', (tester) async { + final fake = _Fake(); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), usage: fake)); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: Row(children: [const Spacer(), AppActions(c: c, onExit: () {})])))); + expect(find.textContaining('--%', findRichText: true), findsNWidgets(2)); // 아직 값이 없음 + + fake.next = const UsageSample(cpu: 0.234, memUsed: 6 << 30, memTotal: 16 << 30); + await tester.pump(const Duration(seconds: 2)); + await tester.pump(); + expect(find.textContaining('CPU 23%', findRichText: true), findsOneWidget); + expect(find.textContaining('MEM 38%', findRichText: true), findsOneWidget); + expect(find.textContaining('DISK', findRichText: true), findsNothing); // 디스크 값을 모르면 숨김 + + // DISK: MEM 뒤, 환경 설정 앞. 남은 용량이 적으면 diskLow. + fake.space = (128 << 30, 931 << 30); + await tester.pump(const Duration(seconds: 2)); + await tester.pump(); + final disk = find.textContaining('DISK 128GB', findRichText: true); + expect(disk, findsOneWidget); + expect(tester.getCenter(disk).dx, + greaterThan(tester.getCenter(find.textContaining('MEM 38%', findRichText: true)).dx)); + expect(tester.getCenter(disk).dx, lessThan(tester.getCenter(find.byTooltip('환경 설정')).dx)); + expect(c.usage!.value!.diskLow, isFalse); + expect(const UsageSample(cpu: 0, memUsed: 1, memTotal: 2, diskFree: 5 << 30, diskTotal: 100 << 30).diskLow, isTrue); + expect(const UsageSample(cpu: 0, memUsed: 1, memTotal: 2, diskFree: 30 << 30, diskTotal: 2000 << 30).diskLow, isTrue); + // 환경 설정 버튼의 왼쪽 + expect(tester.getCenter(find.textContaining('MEM 38%', findRichText: true)).dx, + lessThan(tester.getCenter(find.byTooltip('환경 설정')).dx)); + + // 화면이 사라지면 재는 것도 멈춘다 + await tester.pumpWidget(const SizedBox()); + // ignore: invalid_use_of_protected_member + expect(c.usage!.hasListeners, isFalse); + }); + + test('사용량을 지원하지 않으면 표시 없음', () { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + expect(c.usage, isNull); + }); +} diff --git a/test/ytdlp_net_test.dart b/test/ytdlp_net_test.dart index be22775..124c54a 100644 --- a/test/ytdlp_net_test.dart +++ b/test/ytdlp_net_test.dart @@ -65,4 +65,22 @@ void main() { expect(t.state, DownloadState.done, reason: t.error); expect(t.extra['cookieStage'], 2); }, timeout: const Timeout(Duration(minutes: 3))); + + test('실제 재생목록: 고른 브라우저의 쿠키를 못 읽어도 목록을 읽는다', () async { + if (Platform.environment['JJ_NET_TESTS'] != '1') return markTestSkipped('JJ_NET_TESTS=1 일 때만'); + final tools = p.join(Directory.current.path, 'third_party', 'tools', 'windows'); + final dir = Directory.systemTemp.createTempSync('jj_ytnet3_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final backend = YtDlpBackend( + ytdlp: p.join(tools, 'yt-dlp.exe'), + deno: p.join(tools, 'deno.exe'), + cookieArgs: () => ['--cookies-from-browser', 'firefox:${p.join(dir.path, 'no_such_profile')}'], + ); + final r = await backend.expandPlaylist( + 'https://www.youtube.com/watch?v=sreCHQ6TxRU&list=PLJE1o6D6cpQ3v1uqaCD59Zffu7FGgQOhc'); + // ignore: avoid_print + print('RESULT 재생목록 "${r?.$1}" 영상 ${r?.$2.length}개'); + expect(r, isNotNull); + expect(r!.$2, isNotEmpty); + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/tool/next_version.ps1 b/tool/next_version.ps1 new file mode 100644 index 0000000..745f682 --- /dev/null +++ b/tool/next_version.ps1 @@ -0,0 +1,27 @@ +# 다음 버전 번호를 정해 pubspec.yaml 에 적는다. +# +# 버전: 년.월.일_순번 (예: 2026.09.30_001) +# - 같은 날 다시 내면 순번 +1 +# - 날이 바뀌면 001 부터 다시 +# pubspec.yaml 에는 Flutter 형식인 "2026.9.30+1" 로 적힌다 (앞의 0 은 쓸 수 없음). +# 프로그램 제목 · 업데이트 창 · 태그 · zip 이름에는 "2026.09.30_001" 로 보인다. +# +# 사용: powershell -File tool\next_version.ps1 → 번호를 올리고 새 번호 출력 +# powershell -File tool\next_version.ps1 -Show → 지금 번호만 출력 +param([switch]$Show) + +$pubspec = Join-Path $PSScriptRoot '..\pubspec.yaml' +$text = [IO.File]::ReadAllText($pubspec) +if ($text -notmatch '(?m)^version:\s*(\d+)\.(\d+)\.(\d+)\+(\d+)\s*$') { throw 'pubspec.yaml 에서 version 을 찾을 수 없습니다.' } +$y = [int]$Matches[1]; $m = [int]$Matches[2]; $d = [int]$Matches[3]; $n = [int]$Matches[4] + +function Format-Version($y, $m, $d, $n) { '{0}.{1:00}.{2:00}_{3:000}' -f $y, $m, $d, $n } + +if ($Show) { Format-Version $y $m $d $n; return } + +$today = Get-Date +if ($y -eq $today.Year -and $m -eq $today.Month -and $d -eq $today.Day) { $n++ } else { $n = 1 } +$y = $today.Year; $m = $today.Month; $d = $today.Day +$text = [regex]::Replace($text, '(?m)^version:.*$', "version: $y.$m.$d+$n") +[IO.File]::WriteAllText($pubspec, $text, (New-Object Text.UTF8Encoding $false)) +Format-Version $y $m $d $n