v2026.09.30_001: 일괄 작업, 다운로드 진행률 · 쿠키 재시도, 상태 표시

- 버전 표기를 년.월.일_순번 으로 변경 (tool/next_version.ps1)
- 동영상 목록 여러 개 선택 · 정렬, 자막 만들기 / 자막 만들기 & MKV 만들기 /
  선택한 파일 재생 일괄 실행
- 다운로드: 동영상 추가 버튼, 완료시 자동 동영상추가, 받은 크기 / 전체 크기,
  yt-dlp 진행률 출력 복구 (--no-quiet --progress), 영상 + 음성 진행률 합산
- YouTube 쿠키를 못 읽으면 앱 안 브라우저 쿠키 → 쿠키 없이 순서로 재시도
- 모든 화면 오른쪽 위에 CPU · MEM · DISK, 환경 설정 · 종료 버튼
- 단축키로 창 보이기 / 숨기기 전환, 종료 시간 단축 (exit.log 에 단계별 시간)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main v2026.09.30_001
RyuJongJin 2026-09-30 21:31:07 +09:00
parent c05a657756
commit 1ba49ffa8d
24 changed files with 1222 additions and 87 deletions

View File

@ -26,6 +26,8 @@
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 | | **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 | | **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 | | **작업 대기열 · 화면 분할** | 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+L 주소창 · F5 새로고침 · Alt+←/→ 뒤로/앞으로 · Ctrl+D 즐겨찾기 · Ctrl+Shift+B 즐겨찾기 관리 · Ctrl+Shift+J 작업 현황 |
| 어디서나 | Ctrl+Shift+X 창 보이기 / 트레이로 숨기기 (환경 설정에서 변경) | | 어디서나 | 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 빌드용) 필요: Flutter 3.47+, Visual Studio 2022 Build Tools (C++ 데스크톱), Windows 개발자 모드, PATH 에 `nuget.exe` (앱 안 브라우저 WebView2 빌드용)

View File

@ -23,6 +23,7 @@ import '../services/ai_services.dart';
import '../services/media_tool.dart'; import '../services/media_tool.dart';
import '../services/model_store.dart'; import '../services/model_store.dart';
import '../services/platform_services.dart'; import '../services/platform_services.dart';
import '../services/system_usage.dart';
import 'settings.dart'; import 'settings.dart';
extension<T> on Set<T> { extension<T> on Set<T> {
@ -70,6 +71,54 @@ class AppController extends ChangeNotifier {
Future<bool> Function()? confirmQuit; Future<bool> 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 = <VideoItem, DateTime>{};
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<void> updateSettings(void Function(AppSettings s) change) async { Future<void> updateSettings(void Function(AppSettings s) change) async {
change(settings); change(settings);
_applySettings(); _applySettings();
@ -296,6 +345,52 @@ class AppController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// ───────── 여러 개 선택 (일괄 작업) ─────────
/// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상)
final Set<VideoItem> 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<VideoItem> 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<void> aiThenBuild(List<VideoItem> targets, AiOptions opts) async {
await generateAiSubtitles(targets, opts);
if (_aiCancelled) return;
await buildVideos(targets);
}
/// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수. /// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수.
Future<int> addDownloaded(List<String> files) async { Future<int> addDownloaded(List<String> files) async {
final before = videos.length; final before = videos.length;
@ -310,6 +405,7 @@ class AppController extends ChangeNotifier {
void removeVideo(VideoItem v) { void removeVideo(VideoItem v) {
if (v.status == JobStatus.running) return; if (v.status == JobStatus.running) return;
videos.remove(v); videos.remove(v);
checked.remove(v);
if (selected == v) selected = videos.isEmpty ? null : videos.first; if (selected == v) selected = videos.isEmpty ? null : videos.first;
_saveVideoList(); _saveVideoList();
notifyListeners(); notifyListeners();
@ -318,6 +414,7 @@ class AppController extends ChangeNotifier {
void clearVideos() { void clearVideos() {
if (busy) return; if (busy) return;
videos.clear(); videos.clear();
checked.clear();
selected = null; selected = null;
_saveVideoList(); _saveVideoList();
notifyListeners(); notifyListeners();
@ -864,7 +961,8 @@ class AppController extends ChangeNotifier {
/// 재생 준비. 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null, /// 재생 준비. 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null,
/// 아니면 내장 플레이어용 (목록, 시작 위치). /// 아니면 내장 플레이어용 (목록, 시작 위치).
Future<(List<String>, int)?> preparePlayback(List<String> files) async { /// [keepOrder]: 여러 개를 줄 때 이름순으로 다시 정렬하지 않고 준 순서대로 재생
Future<(List<String>, int)?> preparePlayback(List<String> files, {bool keepOrder = false}) async {
final videos = files.where(isVideoFile).toList(); final videos = files.where(isVideoFile).toList();
if (videos.isEmpty) return null; if (videos.isEmpty) return null;
final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase();
@ -875,7 +973,7 @@ class AppController extends ChangeNotifier {
return null; return null;
} }
if (videos.length > 1) { 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); return (videos, 0);
} }
final siblings = await services.storage.listFiles(p.dirname(videos.first)); final siblings = await services.storage.listFiles(p.dirname(videos.first));
@ -905,11 +1003,20 @@ class AppController extends ChangeNotifier {
return _enqueue('MKV 만들기', _runBuildAll); return _enqueue('MKV 만들기', _runBuildAll);
} }
Future<void> _runBuildAll() async { /// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로)
Future<void> buildVideos(List<VideoItem> 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<void> _runBuildAll([List<VideoItem>? only]) async {
_buildCancelled = false; _buildCancelled = false;
try { try {
// 동시에 [maxParallelJobs] 개씩 (0 = 무제한). 작업자가 목록에서 하나씩 가져가 처리한다. // 동시에 [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; final limit = settings.maxParallelJobs <= 0 ? queue.length : settings.maxParallelJobs;
if (queue.length > 1) _log('MKV 만들기: ${queue.length}개, 동시에 ${limit.clamp(1, queue.length)}개씩'); if (queue.length > 1) _log('MKV 만들기: ${queue.length}개, 동시에 ${limit.clamp(1, queue.length)}개씩');
var next = 0; var next = 0;

View File

@ -34,6 +34,28 @@ class DownloadManager extends ChangeNotifier {
Timer? _clipTimer; Timer? _clipTimer;
String? _lastClip; String? _lastClip;
/// 받은 동영상을 편집 목록(MKV 만들기)에 넣는 함수 (새로 넣은 개수를 돌려줌). 앱이 연결해 준다.
Future<int> Function(List<String> files)? addToEditList;
/// "완료시 자동 동영상추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다.
Future<void> Function(bool on)? setAutoAdd;
/// 설정이 바뀌었을 때 화면 갱신
void refresh() => notifyListeners();
/// 목록에서만 뺀다 (받은 파일은 그대로)
void dropFromList(DownloadTask t) {
tasks.remove(t);
selected.remove(t.id);
notifyListeners();
}
/// 고른 다운로드 중 다 받은 것의 동영상 파일
List<String> get selectedVideoFiles => [
for (final t in _sel)
if (t.state == DownloadState.done) ...videoFilesOf(t),
];
/// 새로 추가된 다운로드 알림 (화면에 안내 표시용) /// 새로 추가된 다운로드 알림 (화면에 안내 표시용)
final _added = StreamController<DownloadTask>.broadcast(); final _added = StreamController<DownloadTask>.broadcast();
Stream<DownloadTask> get onAdded => _added.stream; Stream<DownloadTask> get onAdded => _added.stream;
@ -180,7 +202,7 @@ class DownloadManager extends ChangeNotifier {
holder holder
..expanding = false ..expanding = false
..state = DownloadState.failed ..state = DownloadState.failed
..error = '$e'; ..error = e is ProcessException ? e.message : '$e';
notifyListeners(); notifyListeners();
} }
} }

View File

@ -1,10 +1,22 @@
/// GitHub 저장소 (업데이트 확인) /// GitHub 저장소 (업데이트 확인)
const updateRepo = 'RyuJongJin/JJ_MKVMaker'; 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<int> parseVersion(String v) { List<int> parseVersion(String v) {
final core = v.trim().replaceFirst(RegExp(r'^[vV]'), '').split(RegExp(r'[+\-\s]')).first; final core = v.trim().replaceFirst(RegExp(r'^[vV]'), '').split(RegExp(r'[\-\s]')).first;
return [for (final part in core.split('.')) int.tryParse(part) ?? 0]; 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 면 양수 /// a < b 면 음수, 같으면 0, a > b 면 양수
@ -19,8 +31,8 @@ int compareVersions(String a, String b) {
/// GitHub Release 정보 /// GitHub Release 정보
class ReleaseInfo { class ReleaseInfo {
final String version; // 1.0.1 final String version; // 2026.09.30_001
final String tag; // v1.0.1 final String tag; // v2026.09.30_001
final String name; final String name;
final String notes; final String notes;
final String pageUrl; final String pageUrl;
@ -69,7 +81,7 @@ ReleaseInfo? parseLatestRelease(Map<String, dynamic> j) {
sha = RegExp(r'\b([0-9a-fA-F]{64})\b').firstMatch(j['body'] as String? ?? '')?.group(1)?.toLowerCase(); sha = RegExp(r'\b([0-9a-fA-F]{64})\b').firstMatch(j['body'] as String? ?? '')?.group(1)?.toLowerCase();
} }
return ReleaseInfo( return ReleaseInfo(
version: parseVersion(tag).join('.'), version: formatVersion(tag),
tag: tag, tag: tag,
name: j['name'] as String? ?? tag, name: j['name'] as String? ?? tag,
notes: j['body'] as String? ?? '', notes: j['body'] as String? ?? '',

View File

@ -11,6 +11,7 @@ import 'app/app_controller.dart';
import 'app/bookmarks_controller.dart'; import 'app/bookmarks_controller.dart';
import 'app/download_manager.dart'; import 'app/download_manager.dart';
import 'app/settings.dart'; import 'app/settings.dart';
import 'core/app_update.dart';
import 'core/playlist.dart'; import 'core/playlist.dart';
import 'platform/windows/desktop_shell.dart'; import 'platform/windows/desktop_shell.dart';
import 'platform/windows/exit_trace.dart'; import 'platform/windows/exit_trace.dart';
@ -18,6 +19,7 @@ import 'platform/windows/single_instance.dart';
import 'platform/windows/window_memory.dart'; import 'platform/windows/window_memory.dart';
import 'services/platform_services.dart'; import 'services/platform_services.dart';
import 'ui/ai_dialog.dart'; import 'ui/ai_dialog.dart';
import 'ui/app_actions.dart';
import 'ui/browser_page.dart'; import 'ui/browser_page.dart';
import 'ui/downloads_page.dart'; import 'ui/downloads_page.dart';
import 'ui/exit_dialog.dart'; import 'ui/exit_dialog.dart';
@ -33,7 +35,8 @@ String appTitle = 'JJ_MKVMaker';
/// 버전을 읽어 제목을 정하고, 설정 폴더를 돌려준다 /// 버전을 읽어 제목을 정하고, 설정 폴더를 돌려준다
Future<String> _prepare() async { Future<String> _prepare() async {
try { try {
appTitle = 'JJ_MKVMaker v${(await PackageInfo.fromPlatform()).version}'; final info = await PackageInfo.fromPlatform();
appTitle = 'JJ_MKVMaker v${formatVersion('${info.version}+${info.buildNumber}')}';
} catch (_) {} } catch (_) {}
return (await getApplicationSupportDirectory()).path; return (await getApplicationSupportDirectory()).path;
} }
@ -166,10 +169,19 @@ Future<void> main(List<String> args) async {
downloads.onFinished.listen((t) async { downloads.onFinished.listen((t) async {
if (!controller.settings.addFinishedDownloads) return; if (!controller.settings.addFinishedDownloads) return;
final files = DownloadManager.videoFilesOf(t); final files = DownloadManager.videoFilesOf(t);
if (files.isNotEmpty && await controller.addDownloaded(files) > 0) { if (files.isEmpty) return;
messengerKey.currentState?.showSnackBar(SnackBar(content: Text('편집 목록에 추가: ${t.title}'))); 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()); unawaited(downloads.startClipboardWatch());
// 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리 // 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리
@ -270,7 +282,7 @@ Future<void> runSecondWindow(List<String> files, String dataDir) async {
await controller.init(); await controller.init();
controller.settings = await SettingsStore().load(); controller.settings = await SettingsStore().load();
final navigatorKey = GlobalKey<NavigatorState>(); final navigatorKey = GlobalKey<NavigatorState>();
runApp(JjCapCutApp(controller: controller, navigatorKey: navigatorKey)); runApp(JjCapCutApp(controller: controller, navigatorKey: navigatorKey, onExit: () => exit(0)));
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
await controller.shareVideoList(p.join(dataDir, 'videos.json')); await controller.shareVideoList(p.join(dataDir, 'videos.json'));
unawaited(controller.addVideos(files, allowOutputFolder: true)); unawaited(controller.addVideos(files, allowOutputFolder: true));
@ -304,6 +316,7 @@ class JjCapCutApp extends StatelessWidget {
theme: buildTheme(), theme: buildTheme(),
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey, scaffoldMessengerKey: messengerKey,
builder: (context, child) => AppScope(controller: controller, onExit: onExit, child: child!),
home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks), home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
); );
} }

View File

@ -27,8 +27,12 @@ class GitHubUpdater implements Updater {
}) : appDir = appDir ?? p.dirname(Platform.resolvedExecutable); }) : appDir = appDir ?? p.dirname(Platform.resolvedExecutable);
@override @override
Future<String> currentVersion() async => Future<String> currentVersion() async {
versionOverride ?? (await PackageInfo.fromPlatform()).version; 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 @override
Future<ReleaseInfo?> latest() async { Future<ReleaseInfo?> latest() async {

View File

@ -0,0 +1,77 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import '../../services/system_usage.dart';
typedef _GetSystemTimesC = Int32 Function(Pointer<Uint64> idle, Pointer<Uint64> kernel, Pointer<Uint64> user);
typedef _GetSystemTimesD = int Function(Pointer<Uint64> idle, Pointer<Uint64> kernel, Pointer<Uint64> user);
typedef _MemStatusC = Int32 Function(Pointer<Uint8> buffer);
typedef _MemStatusD = int Function(Pointer<Uint8> buffer);
typedef _DiskFreeC = Int32 Function(
Pointer<Utf16> dir, Pointer<Uint64> freeForUser, Pointer<Uint64> total, Pointer<Uint64> free);
typedef _DiskFreeD = int Function(
Pointer<Utf16> dir, Pointer<Uint64> freeForUser, Pointer<Uint64> total, Pointer<Uint64> 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<Uint64>(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<Uint64>(3);
// MEMORYSTATUSEX: 길이(4) · 사용률(4) · 전체 물리(8) · 남은 물리(8) · … 모두 64바이트
final m = calloc<Uint8>(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<Uint32>().value = 64;
if (_mem(m) == 0) return null;
final memTotal = (m + 8).cast<Uint64>().value;
final memFree = (m + 16).cast<Uint64>().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);
}
}
}

View File

@ -69,18 +69,28 @@ class YtDlpBackend implements DownloadBackend {
final listId = youtubePlaylistId(url); final listId = youtubePlaylistId(url);
if (listId == null) return null; if (listId == null) return null;
// 영상은 받지 않고 목록만 빠르게 읽는다 // 영상은 받지 않고 목록만 빠르게 읽는다
final r = await Process.run( Future<ProcessResult> read(List<String> cookies) => Process.run(
ytdlp, ytdlp,
[ [
'--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8', '--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8',
if (deno != null) ...['--js-runtimes', 'deno:$deno'], if (deno != null) ...['--js-runtimes', 'deno:$deno'],
...cookieArgs(), ...cookies,
'https://www.youtube.com/playlist?list=$listId', 'https://www.youtube.com/playlist?list=$listId',
], ],
environment: _env, environment: _env,
stdoutEncoding: utf8, stdoutEncoding: utf8,
stderrEncoding: utf8, stderrEncoding: utf8,
); );
// 고른 브라우저의 쿠키를 못 읽으면: 앱 안 브라우저의 쿠키 → 쿠키 없이 (영상 받기와 같은 순서)
final tries = <List<String>>[];
for (final c in [cookieArgs(), fallbackCookieArgs(), const <String>[]]) {
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) { if (r.exitCode != 0) {
throw ProcessException( throw ProcessException(
ytdlp, [url], '재생목록을 읽을 수 없습니다: ${friendlyYtDlpError((r.stderr as String).trim())}', r.exitCode); ytdlp, [url], '재생목록을 읽을 수 없습니다: ${friendlyYtDlpError((r.stderr as String).trim())}', r.exitCode);

View File

@ -14,6 +14,7 @@ import '../platform/windows/desktop_shell.dart';
import '../platform/windows/desktop_storage_service.dart'; import '../platform/windows/desktop_storage_service.dart';
import '../platform/windows/github_updater.dart'; import '../platform/windows/github_updater.dart';
import '../platform/windows/process_media_tool.dart'; import '../platform/windows/process_media_tool.dart';
import '../platform/windows/windows_usage.dart';
import '../platform/windows/ytdlp_backend.dart'; import '../platform/windows/ytdlp_backend.dart';
import 'ai_services.dart'; import 'ai_services.dart';
import 'app_shell.dart'; import 'app_shell.dart';
@ -24,6 +25,7 @@ import 'model_store.dart';
import 'preview_player.dart'; import 'preview_player.dart';
import 'storage_service.dart'; import 'storage_service.dart';
import 'subtitle_provider.dart'; import 'subtitle_provider.dart';
import 'system_usage.dart';
import 'updater.dart'; import 'updater.dart';
/// 플랫폼별 구현을 한 곳에서 고른다. /// 플랫폼별 구현을 한 곳에서 고른다.
@ -52,6 +54,9 @@ class PlatformServices {
/// 새 버전 확인 · 설치 (없으면 업데이트 기능 숨김) /// 새 버전 확인 · 설치 (없으면 업데이트 기능 숨김)
final Updater? updater; final Updater? updater;
/// PC 의 CPU · 메모리 사용량 (없으면 표시 숨김)
final SystemUsage? usage;
/// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다) /// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다)
final List<SubtitleProvider> Function(AppSettings Function() settings)? createSubtitleProviders; final List<SubtitleProvider> Function(AppSettings Function() settings)? createSubtitleProviders;
@ -65,6 +70,7 @@ class PlatformServices {
this.createDownloadBackends, this.createDownloadBackends,
this.createSubtitleProviders, this.createSubtitleProviders,
this.updater, this.updater,
this.usage,
ModelStore? models, ModelStore? models,
AppShell? shell, AppShell? shell,
}) : models = models ?? ModelStore(), }) : models = models ?? ModelStore(),
@ -99,6 +105,7 @@ class PlatformServices {
], ],
shell: DesktopShell(), shell: DesktopShell(),
updater: GitHubUpdater(), updater: GitHubUpdater(),
usage: Platform.isWindows ? WindowsUsage() : null,
createSubtitleProviders: (s) => [ createSubtitleProviders: (s) => [
OpenSubtitlesProvider( OpenSubtitlesProvider(
apiKey: () => s().openSubtitlesKey, apiKey: () => s().openSubtitlesKey,

View File

@ -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<UsageSample?> {
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();
}
}

View File

@ -7,28 +7,36 @@ import '../services/model_store.dart';
import 'theme.dart'; import 'theme.dart';
/// AI 자막 만들기 설정 창 /// AI 자막 만들기 설정 창
/// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴) /// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴 · 여러 개 선택)
/// [thenBuild]: 자막을 만든 뒤 그 동영상들을 이어서 MKV 로 만든다
Future<void> showAiDialog(BuildContext context, AppController c, VideoItem current, Future<void> showAiDialog(BuildContext context, AppController c, VideoItem current,
{List<VideoItem>? targets}) async { {List<VideoItem>? targets, bool thenBuild = false}) async {
final list = targets ?? [current]; final list = targets ?? [current];
Future<void> run(List<VideoItem> v, AiOptions o) =>
thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o);
// 환경 설정에서 "매번 묻기" 를 끄면 마지막 설정으로 바로 시작 // 환경 설정에서 "매번 묻기" 를 끄면 마지막 설정으로 바로 시작
if (!c.settings.askAiOptions) { if (!c.settings.askAiOptions) {
await c.generateAiSubtitles(list, c.aiOptions); await run(list, c.aiOptions);
return; return;
} }
final result = await showDialog<(AiOptions, bool)>( final result = await showDialog<(AiOptions, bool)>(
context: context, 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; if (result == null) return;
final (opts, all) = result; 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 { class _AiDialog extends StatefulWidget {
final AppController c; final AppController c;
final int videoCount; 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 @override
State<_AiDialog> createState() => _AiDialogState(); State<_AiDialog> createState() => _AiDialogState();
@ -60,7 +68,8 @@ class _AiDialogState extends State<_AiDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final extra = languages.where((l) => !_o.targets.contains(l)).toList(); final extra = languages.where((l) => !_o.targets.contains(l)).toList();
return AlertDialog( return AlertDialog(
title: const Text('AI 자막 만들기'), title: Text('AI 자막 만들기${widget.thenBuild ? ' → MKV 만들기' : ''}'
'${widget.targetCount > 1 ? ' (동영상 ${widget.targetCount}개)' : ''}'),
content: SizedBox( content: SizedBox(
width: 520, width: 520,
child: SingleChildScrollView( child: SingleChildScrollView(

129
lib/ui/app_actions.dart Normal file
View File

@ -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<AppScope>();
@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<UsageSample?>(
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<void>(builder: (_) => SettingsPage(c: controller))),
),
if (exit != null)
IconButton(
tooltip: '종료',
icon: const Icon(Icons.power_settings_new, color: JjColors.danger),
onPressed: exit,
),
]);
}
}

View File

@ -10,6 +10,7 @@ import '../app/download_manager.dart';
import '../core/bookmarks.dart'; import '../core/bookmarks.dart';
import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies; import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies;
import '../core/web_address.dart'; import '../core/web_address.dart';
import 'app_actions.dart';
import 'downloads_page.dart'; import 'downloads_page.dart';
import 'theme.dart'; import 'theme.dart';
import 'work_panel.dart'; import 'work_panel.dart';
@ -179,7 +180,9 @@ class _BrowserPageState extends State<BrowserPage> {
child: Scaffold( child: Scaffold(
body: ListenableBuilder( body: ListenableBuilder(
listenable: Listenable.merge([bm, widget.c]), listenable: Listenable.merge([bm, widget.c]),
builder: (context, _) => Row(children: [ builder: (context, _) => Column(children: [
_toolbar(),
Expanded(child: Row(children: [
if (_panel) if (_panel)
SizedBox( SizedBox(
width: 320, width: 320,
@ -195,7 +198,6 @@ class _BrowserPageState extends State<BrowserPage> {
if (_panel) const VerticalDivider(width: 1), if (_panel) const VerticalDivider(width: 1),
Expanded( Expanded(
child: Column(children: [ child: Column(children: [
_toolbar(),
_BookmarkBar(bm: bm, onOpen: _go, onOpenExternal: _openExternal), _BookmarkBar(bm: bm, onOpen: _go, onOpenExternal: _openExternal),
SizedBox( SizedBox(
height: 2, height: 2,
@ -222,6 +224,7 @@ class _BrowserPageState extends State<BrowserPage> {
), ),
), ),
], ],
])),
]), ]),
), ),
), ),
@ -238,9 +241,9 @@ class _BrowserPageState extends State<BrowserPage> {
onPressed: f, onPressed: f,
); );
return Container( return Container(
height: 48, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 6), padding: const EdgeInsets.only(left: 6, right: appBarRightPadding),
child: Row(children: [ child: Row(children: [
btn(Icons.video_library_outlined, 'MKV 화면으로', () => Navigator.maybePop(context), color: JjColors.accent), btn(Icons.video_library_outlined, 'MKV 화면으로', () => Navigator.maybePop(context), color: JjColors.accent),
const SizedBox(width: 4), const SizedBox(width: 4),
@ -295,6 +298,7 @@ class _BrowserPageState extends State<BrowserPage> {
_toggleWork, color: widget.c.busy ? JjColors.accent : null), _toggleWork, color: widget.c.busy ? JjColors.accent : null),
btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, '즐겨찾기 관리 (Ctrl+Shift+B)', btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, '즐겨찾기 관리 (Ctrl+Shift+B)',
() => setState(() => _panel = !_panel)), () => setState(() => _panel = !_panel)),
AppActions(c: widget.c),
]), ]),
); );
} }

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../app/download_manager.dart'; import '../app/download_manager.dart';
import '../core/download_detect.dart'; import '../core/download_detect.dart';
import '../services/downloader.dart'; import '../services/downloader.dart';
import 'app_actions.dart';
import 'theme.dart'; import 'theme.dart';
/// 다운로드 목록 /// 다운로드 목록
@ -45,9 +46,9 @@ class _DownloadsPageState extends State<DownloadsPage> {
return Scaffold( return Scaffold(
body: Column(children: [ body: Column(children: [
Container( Container(
height: 56, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [ child: Row(children: [
IconButton( IconButton(
tooltip: '돌아가기', tooltip: '돌아가기',
@ -71,6 +72,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FilledButton(onPressed: _add, child: const Text('추가')), FilledButton(onPressed: _add, child: const Text('추가')),
const AppActions(),
]), ]),
), ),
Container( Container(
@ -94,6 +96,30 @@ class _DownloadsPageState extends State<DownloadsPage> {
]), ]),
), ),
const Spacer(), 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.pause, '일시정지', hasSel ? d.pauseSelected : null),
_btn(Icons.play_arrow, '재개', hasSel ? d.resumeSelected : null), _btn(Icons.play_arrow, '재개', hasSel ? d.resumeSelected : null),
_btn(Icons.stop, '취소', hasSel ? d.cancelSelected : null), _btn(Icons.stop, '취소', hasSel ? d.cancelSelected : null),
@ -129,6 +155,17 @@ class _DownloadsPageState extends State<DownloadsPage> {
), ),
); );
/// 고른 (다 받은) 동영상을 MKV 만들기의 동영상 목록에 추가
Future<void> _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<void> _confirmRemove(BuildContext context) async { Future<void> _confirmRemove(BuildContext context) async {
final n = widget.d.selected.length; final n = widget.d.selected.length;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(

View File

@ -13,10 +13,10 @@ import '../core/models.dart';
import '../core/output_paths.dart'; import '../core/output_paths.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
import 'ai_dialog.dart'; import 'ai_dialog.dart';
import 'app_actions.dart';
import 'browser_page.dart'; import 'browser_page.dart';
import 'downloads_page.dart'; import 'downloads_page.dart';
import 'player_page.dart'; import 'player_page.dart';
import 'settings_page.dart';
import 'subtitle_editor_page.dart'; import 'subtitle_editor_page.dart';
import 'subtitle_search_dialog.dart'; import 'subtitle_search_dialog.dart';
import 'theme.dart'; import 'theme.dart';
@ -108,13 +108,21 @@ class _TopBar extends StatelessWidget {
final BookmarksController? bookmarks; final BookmarksController? bookmarks;
const _TopBar({required this.c, this.downloads, this.onExit, this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final canBuild = c.videos.isNotEmpty && c.ffmpegVersion != null; 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( return Container(
height: 56, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.only(left: 16, right: appBarRightPadding),
child: Row( child: Row(
children: [ children: [
// 프로그램 아이콘 (tool/make_icon.py 로 생성) // 프로그램 아이콘 (tool/make_icon.py 로 생성)
@ -123,18 +131,42 @@ class _TopBar extends StatelessWidget {
const Text('JJ_MKVMaker', const Text('JJ_MKVMaker',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
const SizedBox(width: 24), const SizedBox(width: 24),
OutlinedButton.icon( // 왼쪽 묶음: 창이 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 버튼이 밀려나지 않도록)
onPressed: c.pickVideos, Expanded(
icon: const Icon(Icons.add, size: 18), child: SingleChildScrollView(
label: const Text('동영상 추가'), 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), 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) ...[ if (c.busy) ...[
JobIndicator(c: c), JobIndicator(c: c),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -145,10 +177,27 @@ class _TopBar extends StatelessWidget {
), ),
const SizedBox(width: 8), 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( 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), 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) ...[ if (bookmarks != null) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
@ -165,19 +214,8 @@ class _TopBar extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
_DownloadBox(d: downloads!), _DownloadBox(d: downloads!),
], ],
const SizedBox(width: 4), // 환경 설정 · 종료: 모든 화면에서 같은 자리
IconButton( AppActions(c: c, onExit: onExit),
tooltip: '환경 설정',
icon: const Icon(Icons.settings_outlined),
onPressed: () => Navigator.push(
context, MaterialPageRoute<void>(builder: (_) => SettingsPage(c: c))),
),
if (onExit != null)
IconButton(
tooltip: '종료',
icon: const Icon(Icons.power_settings_new, color: JjColors.danger),
onPressed: onExit,
),
], ],
), ),
); );
@ -313,6 +351,23 @@ class _VideoList extends StatelessWidget {
final AppController c; final AppController c;
const _VideoList({required this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@ -320,10 +375,47 @@ class _VideoList extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 전체 선택 · 선택 개수 · 선택 제거 · 모두 지우기
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), padding: const EdgeInsets.fromLTRB(8, 6, 4, 2),
child: Text('동영상 ${c.videos.length}개', child: Row(children: [
style: const TextStyle(color: JjColors.textDim, fontSize: 12)), 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( Expanded(
child: ListView.builder( child: ListView.builder(
@ -351,7 +443,7 @@ class _VideoTile extends StatelessWidget {
onDoubleTap: () => playFiles(context, c, [v.path]), onDoubleTap: () => playFiles(context, c, [v.path]),
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), 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( decoration: BoxDecoration(
color: isSel ? JjColors.panelHigh : Colors.transparent, color: isSel ? JjColors.panelHigh : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -360,8 +452,14 @@ class _VideoTile extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// 체크: 여러 개를 골라 일괄 작업 (줄을 누르면 지금처럼 한 개 보기)
Checkbox(
visualDensity: VisualDensity.compact,
value: c.checked.contains(v),
onChanged: (_) => c.toggleChecked(v),
),
_StatusIcon(v.status), _StatusIcon(v.status),
const SizedBox(width: 10), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -11,12 +11,14 @@ import '../app/app_controller.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
import '../core/srt.dart' show formatSrtTime; import '../core/srt.dart' show formatSrtTime;
import '../services/media_player.dart'; import '../services/media_player.dart';
import 'app_actions.dart';
import 'theme.dart'; import 'theme.dart';
/// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로) /// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로)
Future<void> playFiles(BuildContext context, AppController c, List<String> files) async { Future<void> playFiles(BuildContext context, AppController c, List<String> files,
{bool keepOrder = false}) async {
final create = c.services.createMediaPlayer; 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; if (plan == null || create == null || !context.mounted) return;
final (list, start) = plan; final (list, start) = plan;
await Navigator.of(context).push(MaterialPageRoute<void>( await Navigator.of(context).push(MaterialPageRoute<void>(
@ -246,9 +248,9 @@ class _PlayerPageState extends State<PlayerPage> {
} }
Widget _header() => Container( Widget _header() => Container(
height: 44, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.only(left: 4, right: appBarRightPadding),
child: Row(children: [ child: Row(children: [
IconButton( IconButton(
tooltip: '돌아가기', tooltip: '돌아가기',
@ -265,6 +267,7 @@ class _PlayerPageState extends State<PlayerPage> {
), ),
), ),
), ),
AppActions(c: c),
]), ]),
); );

View File

@ -12,6 +12,7 @@ import '../core/subtitle_detector.dart';
import '../services/app_shell.dart'; import '../services/app_shell.dart';
import 'setup_dialog.dart'; import 'setup_dialog.dart';
import 'update_dialog.dart'; import 'update_dialog.dart';
import 'app_actions.dart';
import 'theme.dart'; import 'theme.dart';
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 /// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
@ -239,9 +240,9 @@ class _SettingsPageState extends State<SettingsPage> {
return Scaffold( return Scaffold(
body: Column(children: [ body: Column(children: [
Container( Container(
height: 56, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [ child: Row(children: [
IconButton( IconButton(
tooltip: '돌아가기', tooltip: '돌아가기',
@ -251,7 +252,8 @@ class _SettingsPageState extends State<SettingsPage> {
const Text('환경 설정', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Text('환경 설정', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
const Text('바꾸면 바로 저장됩니다', style: TextStyle(fontSize: 12, color: JjColors.textDim)), const Text('바꾸면 바로 저장됩니다', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(width: 12), const SizedBox(width: 8),
AppActions(c: c, onSettingsPage: true),
]), ]),
), ),
Expanded( Expanded(
@ -507,8 +509,8 @@ class _SettingsPageState extends State<SettingsPage> {
SwitchListTile( SwitchListTile(
value: s.addFinishedDownloads, value: s.addFinishedDownloads,
onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v), onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v),
title: const Text('다 받은 동영상을 편집 목록에 자동 추가'), title: const Text('완료시 자동 동영상추가'),
subtitle: const Text('브라우저에서 받은 영상도 바로 자막 만들기 · MKV 만들기를 할 수 있게 목록에 넣습니다'), subtitle: const Text('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)'),
), ),
_section('AI 자막'), _section('AI 자막'),
SwitchListTile( SwitchListTile(

View File

@ -10,6 +10,7 @@ import '../core/models.dart';
import '../core/srt.dart'; import '../core/srt.dart';
import '../core/text_codec.dart'; import '../core/text_codec.dart';
import '../services/preview_player.dart'; import '../services/preview_player.dart';
import 'app_actions.dart';
import 'theme.dart'; import 'theme.dart';
import 'timeline.dart'; import 'timeline.dart';
@ -471,9 +472,9 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
} }
Widget _toolbar(BuildContext context) => Container( Widget _toolbar(BuildContext context) => Container(
height: 56, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
@ -536,6 +537,7 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
icon: const Icon(Icons.save, size: 18), icon: const Icon(Icons.save, size: 18),
label: Text(e.dirty ? '저장 (MKV 반영) *' : '저장 (MKV 반영)'), label: Text(e.dirty ? '저장 (MKV 반영) *' : '저장 (MKV 반영)'),
), ),
const AppActions(),
], ],
), ),
); );

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.13.4 sdk: ^3.13.4

342
test/batch_test.dart Normal file
View File

@ -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 = <String>[];
@override
Future<List<Cue>> 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<void> load(String modelDir) async {}
@override
Future<List<String>> translate(List<String> lines,
{required String source, required String target, AiProgress? onProgress}) async =>
[for (final l in lines) '[$target] $l'];
@override
void cancel() {}
@override
Future<void> dispose() async {}
}
class _Backend implements DownloadBackend {
@override
DownloadKind get kind => DownloadKind.video;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading;
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> 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<String> 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 = <String>[];
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<OutlinedButton>(
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<Checkbox>(
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();
});
}

View File

@ -34,6 +34,24 @@ void main() {
expect(compareVersions('1.10.0', '1.9.9'), greaterThan(0)); expect(compareVersions('1.10.0', '1.9.9'), greaterThan(0));
expect(compareVersions('1.0.0+5', 'v1.0.1'), lessThan(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 해석', () { test('releases/latest 해석', () {
final r = parseLatestRelease(_release('v1.2.0', digest: 'sha256:${'A' * 64}'))!; 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]); expect([r.version, r.tag, r.zipName, r.sha256], ['1.2.0', 'v1.2.0', 'JJ_MKVMaker_v1.2.0_win64.zip', 'a' * 64]);

94
test/usage_test.dart Normal file
View File

@ -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);
});
}

View File

@ -65,4 +65,22 @@ void main() {
expect(t.state, DownloadState.done, reason: t.error); expect(t.state, DownloadState.done, reason: t.error);
expect(t.extra['cookieStage'], 2); expect(t.extra['cookieStage'], 2);
}, timeout: const Timeout(Duration(minutes: 3))); }, 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)));
} }

27
tool/next_version.ps1 Normal file
View File

@ -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