v1.1.1: 더블클릭 재생, 창 위치 · 목록 기억, 종료 방식 설정
- 탐색기에서 연 동영상: 바로 재생 / 목록에 추가, 켜져 있는 창 / 새 창 (설정) jj_mkv 폴더의 파일을 열면 창만 뜨던 문제 수정 - 재생한 동영상을 목록에 추가, 목록을 videos.json 에 저장하고 창끼리 맞춤 - 창 위치 · 크기 · 최대화 기억 (window.json) - 제목 표시줄에 버전 표시 - 종료를 누르면: 백그라운드로 계속 (기본) / 모두 종료 / 매번 고르기 트레이 "종료" 는 항상 종료 - 종료 진행 창 (단계 표시, 단계별 시간 제한) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>old-history-local
parent
960a695c7e
commit
408cf3101a
|
|
@ -21,7 +21,8 @@
|
|||
| **자막 번역** | 이미 있는 자막(예: 영어)을 원하는 언어로 번역해 MKV 에 추가. 인터넷에서 받은 자막도 한국어로 함께 번역 |
|
||||
| **인터넷 자막 찾기** | OpenSubtitles.com 검색 (영상 해시로 "이 파일용" 자막 우선) |
|
||||
| **동영상 플레이어** | 재생 목록, 전체 화면, 팝업(항상 위), 원본의 0.5·1·2·4배, 자막·음성 트랙 선택, 속도 |
|
||||
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기 |
|
||||
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
|
||||
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
|
||||
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
|
||||
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
|
||||
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
|
@ -172,11 +174,12 @@ class AppController extends ChangeNotifier {
|
|||
await addVideos(paths);
|
||||
}
|
||||
|
||||
Future<void> addVideos(List<String> paths) async {
|
||||
/// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다
|
||||
Future<void> addVideos(List<String> paths, {bool allowOutputFolder = false}) async {
|
||||
for (final path in paths) {
|
||||
if (videos.any((v) => p.equals(v.path, path))) continue;
|
||||
// jj_mkv 출력 폴더 안의 파일은 제외
|
||||
if (p.basename(p.dirname(path)) == outputFolderName) {
|
||||
// jj_mkv 출력 폴더 안의 파일은 제외 (폴더째 넣을 때 만든 결과물이 다시 들어가지 않게)
|
||||
if (!allowOutputFolder && p.basename(p.dirname(path)) == outputFolderName) {
|
||||
_log('건너뜀 (출력 폴더의 파일): ${p.basename(path)}');
|
||||
continue;
|
||||
}
|
||||
|
|
@ -186,6 +189,77 @@ class AppController extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
await _loadVideo(v);
|
||||
}
|
||||
_saveVideoList();
|
||||
}
|
||||
|
||||
// ───────── 동영상 목록 저장 · 창끼리 공유 ─────────
|
||||
//
|
||||
// 목록(경로)을 파일 하나에 적어 둔다. 다시 켜면 그대로 불러오고, 창이 여러 개면
|
||||
// 1초마다 파일을 보고 다른 창에서 넣거나 지운 것을 따라 한다.
|
||||
|
||||
String? _videoListFile;
|
||||
String _videoListSaved = '[]';
|
||||
Timer? _videoListTimer;
|
||||
bool _videoListSyncing = false;
|
||||
|
||||
/// 목록 저장 · 공유 시작 ([file] 을 먼저 불러온다)
|
||||
Future<void> shareVideoList(String file) async {
|
||||
_videoListFile = file;
|
||||
await syncVideoList();
|
||||
_videoListTimer?.cancel();
|
||||
_videoListTimer = Timer.periodic(const Duration(seconds: 1), (_) => syncVideoList());
|
||||
}
|
||||
|
||||
void stopVideoListShare() {
|
||||
_videoListTimer?.cancel();
|
||||
_videoListTimer = null;
|
||||
}
|
||||
|
||||
void _saveVideoList() {
|
||||
final file = _videoListFile;
|
||||
if (file == null || _videoListSyncing) return;
|
||||
final text = jsonEncode([for (final v in videos) v.path]);
|
||||
if (text == _videoListSaved) return;
|
||||
_videoListSaved = text;
|
||||
try {
|
||||
// 다른 창이 읽는 중에 반쯤 쓴 파일을 보지 않도록: 임시 파일 → 이름 바꾸기
|
||||
final tmp = File('$file.$pid.tmp')..writeAsStringSync(text, flush: true);
|
||||
tmp.renameSync(file);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 파일이 바뀌었으면 (다른 창에서 넣거나 지움) 이 창의 목록을 맞춘다
|
||||
Future<void> syncVideoList() async {
|
||||
final file = _videoListFile;
|
||||
if (file == null || _videoListSyncing) return;
|
||||
String text;
|
||||
try {
|
||||
final f = File(file);
|
||||
if (!f.existsSync()) return;
|
||||
text = f.readAsStringSync();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (text == _videoListSaved) return;
|
||||
List<String> want;
|
||||
try {
|
||||
want = (jsonDecode(text) as List).cast<String>();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
_videoListSyncing = true;
|
||||
try {
|
||||
_videoListSaved = text;
|
||||
bool inWant(VideoItem v) => want.any((w) => p.equals(w, v.path));
|
||||
// 작업 중인 동영상은 지우지 않는다
|
||||
videos.removeWhere((v) => !inWant(v) && v.status != JobStatus.running);
|
||||
if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull;
|
||||
notifyListeners();
|
||||
await addVideos([for (final w in want) if (File(w).existsSync()) w], allowOutputFolder: true);
|
||||
} finally {
|
||||
_videoListSyncing = false;
|
||||
_saveVideoList(); // 없는 파일을 뺐거나 맞추는 동안 이 창에서 바꾼 것이 있으면 다시 적는다
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadVideo(VideoItem v) async {
|
||||
|
|
@ -237,6 +311,7 @@ class AppController extends ChangeNotifier {
|
|||
if (v.status == JobStatus.running) return;
|
||||
videos.remove(v);
|
||||
if (selected == v) selected = videos.isEmpty ? null : videos.first;
|
||||
_saveVideoList();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +319,7 @@ class AppController extends ChangeNotifier {
|
|||
if (busy) return;
|
||||
videos.clear();
|
||||
selected = null;
|
||||
_saveVideoList();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ class AppSettings {
|
|||
/// 최소화하면 작업 표시줄 대신 트레이로
|
||||
bool minimizeToTray = true;
|
||||
|
||||
/// 종료 (창 X · 종료 버튼) 를 눌렀을 때:
|
||||
/// 'background' 창만 숨기고 다운로드 · 변환 계속 / 'quit' 묻지 않고 모두 종료 / 'ask' 매번 고르기
|
||||
String closeAction = 'background';
|
||||
|
||||
/// 창 보이기 단축키 (예: Ctrl+Shift+X)
|
||||
String showHotkey = 'Ctrl+Shift+X';
|
||||
|
||||
|
|
@ -58,6 +62,12 @@ class AppSettings {
|
|||
|
||||
/// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl])
|
||||
String startScreen = 'home';
|
||||
|
||||
/// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가
|
||||
String openFileAction = 'play';
|
||||
|
||||
/// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창
|
||||
String openFileWindow = 'same';
|
||||
String homeUrl = 'https://www.youtube.com/';
|
||||
|
||||
/// 앱 안 브라우저 엔진: 'edge' (WebView2, 기본). 'chrome' 은 내려받아 연결 (준비 중)
|
||||
|
|
@ -97,6 +107,7 @@ class AppSettings {
|
|||
'downloadRoot': downloadRoot,
|
||||
'clipboardWatch': clipboardWatch,
|
||||
'minimizeToTray': minimizeToTray,
|
||||
'closeAction': closeAction,
|
||||
'showHotkey': showHotkey,
|
||||
'askAiOptions': askAiOptions,
|
||||
'addFinishedDownloads': addFinishedDownloads,
|
||||
|
|
@ -112,6 +123,8 @@ class AppSettings {
|
|||
'maxParallelJobs': maxParallelJobs,
|
||||
'showLog': showLog,
|
||||
'startScreen': startScreen,
|
||||
'openFileAction': openFileAction,
|
||||
'openFileWindow': openFileWindow,
|
||||
'homeUrl': homeUrl,
|
||||
'browserEngine': browserEngine,
|
||||
'externalBrowser': externalBrowser,
|
||||
|
|
@ -139,6 +152,7 @@ class AppSettings {
|
|||
..downloadRoot = j['downloadRoot'] as String?
|
||||
..clipboardWatch = j['clipboardWatch'] as bool? ?? true
|
||||
..minimizeToTray = j['minimizeToTray'] as bool? ?? true
|
||||
..closeAction = const ['background', 'quit', 'ask'].contains(j['closeAction']) ? j['closeAction'] as String : 'background'
|
||||
..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X'
|
||||
..askAiOptions = j['askAiOptions'] as bool? ?? true
|
||||
..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true
|
||||
|
|
@ -154,6 +168,8 @@ class AppSettings {
|
|||
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
|
||||
..showLog = j['showLog'] as bool? ?? true
|
||||
..startScreen = j['startScreen'] as String? ?? 'home'
|
||||
..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
|
||||
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
|
||||
..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/'
|
||||
..browserEngine = j['browserEngine'] as String? ?? 'edge'
|
||||
..externalBrowser = j['externalBrowser'] as String? ?? 'system'
|
||||
|
|
|
|||
159
lib/main.dart
159
lib/main.dart
|
|
@ -2,30 +2,52 @@ import 'dart:async';
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app/app_controller.dart';
|
||||
import 'app/bookmarks_controller.dart';
|
||||
import 'app/download_manager.dart';
|
||||
import 'app/settings.dart';
|
||||
import 'core/playlist.dart';
|
||||
import 'platform/windows/desktop_shell.dart';
|
||||
import 'platform/windows/single_instance.dart';
|
||||
import 'platform/windows/window_memory.dart';
|
||||
import 'services/platform_services.dart';
|
||||
import 'ui/ai_dialog.dart';
|
||||
import 'ui/browser_page.dart';
|
||||
import 'ui/downloads_page.dart';
|
||||
import 'ui/exit_dialog.dart';
|
||||
import 'ui/home_page.dart';
|
||||
import 'ui/player_page.dart';
|
||||
import 'ui/setup_dialog.dart';
|
||||
import 'ui/update_dialog.dart';
|
||||
import 'ui/theme.dart';
|
||||
|
||||
/// 제목 표시줄 글: "JJ_MKVMaker v1.2.3"
|
||||
String appTitle = 'JJ_MKVMaker';
|
||||
|
||||
/// 버전을 읽어 제목을 정하고, 설정 폴더를 돌려준다
|
||||
Future<String> _prepare() async {
|
||||
try {
|
||||
appTitle = 'JJ_MKVMaker v${(await PackageInfo.fromPlatform()).version}';
|
||||
} catch (_) {}
|
||||
return (await getApplicationSupportDirectory()).path;
|
||||
}
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final dataDir = await _prepare();
|
||||
|
||||
// 이미 실행 중이면 인수(탐색기에서 고른 파일)를 넘기고 끝낸다
|
||||
final request = LaunchRequest.parse(args);
|
||||
final newWindow = wantsNewPlayerWindow(request, await SettingsStore().load());
|
||||
final instance = SingleInstance();
|
||||
if (!await instance.claim(LaunchRequest.parse(args), waitForExit: args.contains('--restart'))) {
|
||||
if (!await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow)) {
|
||||
// 설정이 "새 창에서" 이고 이미 켜져 있으면: 재생만 하는 창으로 따로 뜬다
|
||||
if (newWindow) return runSecondWindow(request.files.where(isVideoFile).toList(), dataDir);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -43,11 +65,66 @@ Future<void> main(List<String> args) async {
|
|||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
final messengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// 종료 요청 (창 X · 트레이 종료 · 종료 버튼): 다운로드 중이면 확인
|
||||
Future<bool> confirmQuit() async {
|
||||
final memory = WindowMemory(p.join(dataDir, 'window.json'), 'main');
|
||||
|
||||
/// 종료 요청. true 를 돌려주면 프로그램을 끝낸다.
|
||||
/// - 창 ✕ · 종료 버튼: 설정 "종료를 누르면" 에 따라 백그라운드로 (창만 숨김) / 묻지 않고 종료 / 고르기
|
||||
/// - 트레이 "종료" · 업데이트 설치 ([force]): 항상 종료 (받는 중인 다운로드가 있으면 확인)
|
||||
Future<bool> confirmQuit({bool force = false}) async {
|
||||
final shell = services.shell;
|
||||
final desktop = shell is DesktopShell ? shell : null;
|
||||
var mode = force || (desktop?.closeFromTray ?? false) ? 'force' : controller.settings.closeAction;
|
||||
// 트레이 아이콘이 없으면 숨긴 창을 끝낼 방법이 없다 → 고르게 한다
|
||||
if (mode == 'background' && !(desktop?.hasTray ?? false)) mode = 'ask';
|
||||
if (mode == 'ask') {
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx != null && !await confirmExit(ctx, downloads)) return false;
|
||||
await downloads.shutdown();
|
||||
if (ctx == null || !ctx.mounted) return false;
|
||||
final n = downloads.activeCount;
|
||||
final pick = await showCloseChoice(ctx,
|
||||
running: [
|
||||
if (controller.currentJob != null) controller.currentJob!,
|
||||
if (n > 0) '다운로드 $n개',
|
||||
].join(' · ').ifEmptyText('진행 중인 작업 없음'),
|
||||
hotkey: controller.settings.showHotkey);
|
||||
if (pick == null) return false;
|
||||
if (pick.$2) controller.updateSettings((x) => x.closeAction = pick.$1);
|
||||
mode = pick.$1;
|
||||
}
|
||||
if (mode == 'background') {
|
||||
await shell.hide();
|
||||
return false;
|
||||
}
|
||||
if (mode == 'force') {
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return false;
|
||||
}
|
||||
final job = controller.currentJob;
|
||||
final wait = controller.pendingJobs.length;
|
||||
final steps = [
|
||||
ExitStep(job == null ? '작업 확인 (진행 중인 작업 없음)' : '작업 중지: $job${wait > 0 ? ' · 대기 $wait개' : ''}',
|
||||
() async => controller.cancel()),
|
||||
ExitStep('다운로드 정리 (yt-dlp · aria2 종료)', downloads.shutdown),
|
||||
ExitStep('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)', () async {
|
||||
while (controller.busy) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}),
|
||||
ExitStep('동영상 목록 · 창 위치 저장', () async {
|
||||
controller.stopVideoListShare();
|
||||
await memory.save();
|
||||
}),
|
||||
ExitStep('창 종료', () => Future<void>.delayed(const Duration(milliseconds: 200))),
|
||||
];
|
||||
final ctx2 = navigatorKey.currentContext;
|
||||
if (ctx2 != null && ctx2.mounted) {
|
||||
await showExitProgress(ctx2, lastWork: lastWorkText(controller), steps: steps);
|
||||
} else {
|
||||
for (final s in steps) {
|
||||
try {
|
||||
await s.run().timeout(const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +135,12 @@ Future<void> main(List<String> args) async {
|
|||
onDownloadsRequested: () => navigatorKey.currentState
|
||||
?.push(MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: downloads))),
|
||||
);
|
||||
// 제목 표시줄에 버전 · 지난번 창 위치에서 열기 · 동영상 목록 불러오기 (창끼리 공유)
|
||||
await windowManager.setTitle(appTitle);
|
||||
final shell = services.shell;
|
||||
if (shell is DesktopShell) memory.paused = () => shell.isPopup;
|
||||
await memory.restoreAndWatch();
|
||||
unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json')));
|
||||
|
||||
// 클립보드로 추가된 다운로드 알림 · 트레이 글
|
||||
downloads.onAdded.listen((t) => messengerKey.currentState
|
||||
|
|
@ -79,11 +162,17 @@ Future<void> main(List<String> args) async {
|
|||
// 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리
|
||||
instance.listen((req) async {
|
||||
await services.shell.show();
|
||||
if (req.files.isEmpty) return;
|
||||
// 프로그램을 파일과 함께 처음 켠 경우: 첫 화면이 뜰 때까지 (최대 10초) 기다린다
|
||||
for (var i = 0; i < 100 && navigatorKey.currentContext == null; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx == null || req.files.isEmpty) return;
|
||||
if (!ctx.mounted) return;
|
||||
if (ctx == null || !ctx.mounted) return;
|
||||
switch (req.action) {
|
||||
case LaunchAction.play:
|
||||
// 재생한 동영상도 MKV 화면의 목록에 넣는다
|
||||
unawaited(controller.addVideos(req.files.where(isVideoFile).toList(), allowOutputFolder: true));
|
||||
await playFiles(ctx, controller, req.files);
|
||||
case LaunchAction.subtitle:
|
||||
await controller.addVideos(req.files.where(isVideoFile).toList());
|
||||
|
|
@ -95,11 +184,23 @@ Future<void> main(List<String> args) async {
|
|||
await showAiDialog(ctx, controller, targets.first, targets: targets);
|
||||
}
|
||||
case LaunchAction.add:
|
||||
await controller.addVideos(req.files.where(isVideoFile).toList());
|
||||
// 탐색기 더블클릭 · 연결 프로그램: 설정에 따라 재생 또는 편집 목록에 추가
|
||||
final videos = req.files.where(isVideoFile).toList();
|
||||
if (videos.isEmpty) return;
|
||||
// 어느 쪽이든 MKV 화면의 목록에는 들어간다
|
||||
final added = controller.addVideos(videos, allowOutputFolder: true);
|
||||
if (controller.settings.openFileAction == 'add') {
|
||||
await added;
|
||||
final first = controller.videos.where((v) => videos.any((f) => p.equals(f, v.path))).firstOrNull;
|
||||
if (first != null) controller.select(first);
|
||||
} else {
|
||||
unawaited(added);
|
||||
await playFiles(ctx, controller, videos);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
controller.confirmQuit = confirmQuit;
|
||||
controller.confirmQuit = () => confirmQuit(force: true); // 업데이트 설치: 항상 종료
|
||||
|
||||
// 처음 화면이 뜬 뒤: 필수 프로그램 점검 (없으면 물어보고 설치) → 새 버전 확인 (하루 한 번)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
|
|
@ -127,6 +228,44 @@ Future<void> main(List<String> args) async {
|
|||
));
|
||||
}
|
||||
|
||||
/// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서")
|
||||
bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) =>
|
||||
s.openFileWindow == 'new' &&
|
||||
r.files.any(isVideoFile) &&
|
||||
(r.action == LaunchAction.play || (r.action == LaunchAction.add && s.openFileAction == 'play'));
|
||||
|
||||
extension on String {
|
||||
String ifEmptyText(String other) => isEmpty ? other : this;
|
||||
}
|
||||
|
||||
/// 종료 창에 보일 "바로 전 작업": 하던 작업, 없으면 마지막 작업 기록 (시각 표시는 뺀다)
|
||||
String lastWorkText(AppController c) {
|
||||
if (c.currentJob != null) return c.currentJob!;
|
||||
if (c.logs.isEmpty) return '없음';
|
||||
return c.logs.last.replaceFirst(RegExp(r'^\[[\d:]+\]\s*'), '').split('\n').first;
|
||||
}
|
||||
|
||||
/// 탐색기에서 연 동영상의 새 창: 바로 재생하고, ← 로 나오면 같은 동영상 목록의 MKV 화면.
|
||||
/// 트레이 · 단축키 · 다운로드 · 브라우저는 원래 창이 맡는다. 닫으면 이 창만 끝난다.
|
||||
/// 동영상 목록은 원래 창과 같은 파일을 보므로 어느 창에서 넣거나 지워도 서로 맞춰진다.
|
||||
/// 설정은 읽기만 한다 (원래 창의 설정 파일을 덮어쓰지 않도록 저장하지 않음).
|
||||
Future<void> runSecondWindow(List<String> files, String dataDir) async {
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setTitle(appTitle);
|
||||
await WindowMemory(p.join(dataDir, 'window.json'), 'second').restoreAndWatch();
|
||||
final controller = AppController(PlatformServices.create());
|
||||
await controller.init();
|
||||
controller.settings = await SettingsStore().load();
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
runApp(JjCapCutApp(controller: controller, navigatorKey: navigatorKey));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await controller.shareVideoList(p.join(dataDir, 'videos.json'));
|
||||
unawaited(controller.addVideos(files, allowOutputFolder: true));
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx != null && ctx.mounted) await playFiles(ctx, controller, files);
|
||||
});
|
||||
}
|
||||
|
||||
class JjCapCutApp extends StatelessWidget {
|
||||
final AppController controller;
|
||||
final DownloadManager? downloads;
|
||||
|
|
@ -147,7 +286,7 @@ class JjCapCutApp extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
title: 'JJ_MKVMaker',
|
||||
title: appTitle,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme(),
|
||||
navigatorKey: navigatorKey,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class DesktopShell with WindowListener implements AppShell {
|
|||
_onDownloads?.call();
|
||||
});
|
||||
menu.addSeparator();
|
||||
item('종료', _requestClose);
|
||||
item('종료', () => _requestClose(fromTray: true));
|
||||
t.setContextMenu(menu);
|
||||
t.setContextMenuTrigger(tray.ContextMenuTrigger.rightClicked);
|
||||
t.addListener((e) {
|
||||
|
|
@ -149,14 +149,23 @@ class DesktopShell with WindowListener implements AppShell {
|
|||
@override
|
||||
Future<void> setTooltip(String text) async => _tray?.setTooltip(text);
|
||||
|
||||
Future<void> _requestClose() async {
|
||||
/// 트레이 아이콘이 있는지 (없으면 창을 숨긴 뒤 끝낼 방법이 없으므로 백그라운드로 보내지 않는다)
|
||||
bool get hasTray => _tray != null;
|
||||
|
||||
/// 지금 처리 중인 종료 요청이 트레이 메뉴 "종료" 에서 왔는지 (항상 완전히 종료)
|
||||
bool closeFromTray = false;
|
||||
|
||||
Future<void> _requestClose({bool fromTray = false}) async {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
closeFromTray = fromTray;
|
||||
try {
|
||||
await show();
|
||||
// 트레이에서 종료: 진행 창을 보여 주려고 창을 연다. 창의 ✕ 는 이미 보이는 상태.
|
||||
if (fromTray) await show();
|
||||
if (await _onClose()) await quit();
|
||||
} finally {
|
||||
_closing = false;
|
||||
closeFromTray = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +182,9 @@ class DesktopShell with WindowListener implements AppShell {
|
|||
Rect? _beforePopup;
|
||||
bool _popup = false;
|
||||
|
||||
/// 팝업 보기 중 (이때의 작은 창 크기는 기억하지 않는다)
|
||||
bool get isPopup => _popup;
|
||||
|
||||
@override
|
||||
Future<void> setFullScreen(bool on) async {
|
||||
if (on && _popup) await setPopup(false);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ class SingleInstance {
|
|||
|
||||
/// 첫 번째 실행이면 true (요청을 받을 준비). 이미 실행 중이면 인수를 넘기고 false.
|
||||
/// [waitForExit]: 재시작 - 기존 창이 닫힐 때까지 (최대 15초) 기다렸다가 첫 번째가 된다.
|
||||
Future<bool> claim(LaunchRequest initial, {bool waitForExit = false}) async {
|
||||
/// [forward] 가 false 면 이미 실행 중이어도 인수를 넘기지 않는다 (새 재생 창으로 따로 뜰 때).
|
||||
Future<bool> claim(LaunchRequest initial, {bool waitForExit = false, bool forward = true}) async {
|
||||
for (var i = 0;; i++) {
|
||||
try {
|
||||
_server = await ServerSocket.bind(InternetAddress.loopbackIPv4, port);
|
||||
|
|
@ -32,6 +33,7 @@ class SingleInstance {
|
|||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
continue;
|
||||
}
|
||||
if (!forward) return false;
|
||||
if (await _forward(initial)) return false;
|
||||
return true; // 다른 프로그램이 포트를 쓰는 경우: 넘기지 못했으므로 그냥 실행
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
/// 창 위치 · 크기 · 최대화 여부를 기억했다가 다음에 같은 자리에서 연다.
|
||||
///
|
||||
/// [key] 별로 따로 기억한다 ('main' = 원래 창, 'second' = 탐색기에서 연 새 창).
|
||||
/// 전체 화면 · 팝업 보기 · 최소화 상태에서는 저장하지 않는다 (그 전의 보통 크기를 유지).
|
||||
class WindowMemory with WindowListener {
|
||||
final String file;
|
||||
final String key;
|
||||
Timer? _debounce;
|
||||
|
||||
/// 팝업 보기처럼 창 크기를 잠깐 바꾸는 동안 true 로 두면 저장하지 않는다
|
||||
bool Function()? paused;
|
||||
|
||||
WindowMemory(this.file, this.key);
|
||||
|
||||
Map<String, dynamic> _readAll() {
|
||||
try {
|
||||
return jsonDecode(File(file).readAsStringSync()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// 저장된 자리로 옮기고, 이후 움직이거나 크기를 바꾸면 자동 저장
|
||||
Future<void> restoreAndWatch() async {
|
||||
try {
|
||||
final m = _readAll()[key];
|
||||
if (m is Map) {
|
||||
final r = Rect.fromLTWH((m['x'] as num).toDouble(), (m['y'] as num).toDouble(),
|
||||
(m['w'] as num).toDouble(), (m['h'] as num).toDouble());
|
||||
// 모니터를 뺐거나 해상도가 바뀌어 화면 밖이면 옮기지 않는다
|
||||
if (r.width >= 400 && r.height >= 300 && await _visible(r)) {
|
||||
await windowManager.setBounds(r);
|
||||
if (m['max'] == true) await windowManager.maximize();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
windowManager.addListener(this);
|
||||
}
|
||||
|
||||
static Future<bool> _visible(Rect r) async {
|
||||
for (final d in await screenRetriever.getAllDisplays()) {
|
||||
final area = (d.visiblePosition ?? Offset.zero) & (d.visibleSize ?? d.size);
|
||||
final hit = area.intersect(r);
|
||||
if (hit.width >= 200 && hit.height >= 100) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
try {
|
||||
if (paused?.call() ?? false) return;
|
||||
if (await windowManager.isFullScreen() || await windowManager.isMinimized()) return;
|
||||
if (!await windowManager.isVisible()) return;
|
||||
final max = await windowManager.isMaximized();
|
||||
final all = _readAll();
|
||||
final old = all[key];
|
||||
if (max && old is Map) {
|
||||
// 최대화: 보통 크기는 그대로 두고 표시만
|
||||
all[key] = {...old, 'max': true};
|
||||
} else {
|
||||
final b = await windowManager.getBounds();
|
||||
all[key] = {'x': b.left, 'y': b.top, 'w': b.width, 'h': b.height, 'max': max};
|
||||
}
|
||||
final tmp = File('$file.$pid.tmp')..writeAsStringSync(jsonEncode(all), flush: true);
|
||||
tmp.renameSync(file);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _later() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), save);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMoved() => _later();
|
||||
@override
|
||||
void onWindowResized() => _later();
|
||||
@override
|
||||
void onWindowMaximize() => _later();
|
||||
@override
|
||||
void onWindowUnmaximize() => _later();
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'theme.dart';
|
||||
|
||||
/// 종료할 때 하는 일 한 가지
|
||||
class ExitStep {
|
||||
final String label;
|
||||
final Future<void> Function() run;
|
||||
const ExitStep(this.label, this.run);
|
||||
}
|
||||
|
||||
/// 종료를 눌렀을 때 고르기: 'quit' 모두 종료 / 'background' 백그라운드로 / null 취소.
|
||||
/// 두 번째 값이 true 면 "다음부터 묻지 않기" (고른 것을 설정에 저장).
|
||||
Future<(String, bool)?> showCloseChoice(BuildContext context, {required String running, required String hotkey}) =>
|
||||
showDialog<(String, bool)>(context: context, builder: (_) => _CloseChoice(running: running, hotkey: hotkey));
|
||||
|
||||
class _CloseChoice extends StatefulWidget {
|
||||
final String running, hotkey;
|
||||
const _CloseChoice({required this.running, required this.hotkey});
|
||||
|
||||
@override
|
||||
State<_CloseChoice> createState() => _CloseChoiceState();
|
||||
}
|
||||
|
||||
class _CloseChoiceState extends State<_CloseChoice> {
|
||||
bool _remember = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('종료할까요?'),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('지금: ${widget.running}', style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 10),
|
||||
Text('· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 (${widget.hotkey} 또는 트레이 아이콘으로 다시 열기)\n'
|
||||
|
||||
'· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다',
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
onTap: () => setState(() => _remember = !_remember),
|
||||
child: Row(children: [
|
||||
Checkbox(value: _remember, onChanged: (v) => setState(() => _remember = v ?? false)),
|
||||
const Flexible(
|
||||
child: Text('다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)', style: TextStyle(fontSize: 12))),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')),
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context, ('quit', _remember)), child: const Text('모두 종료')),
|
||||
FilledButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context, ('background', _remember)),
|
||||
child: const Text('백그라운드로')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 종료 진행 창: "종료 중입니다." → 바로 전 작업 → 단계별 진행 → "Have You Good Time"
|
||||
///
|
||||
/// 각 단계는 실패해도 (또는 [stepTimeout] 을 넘겨도) 다음으로 넘어간다 - 종료가 멈추면 안 되므로.
|
||||
Future<void> showExitProgress(
|
||||
BuildContext context, {
|
||||
required String lastWork,
|
||||
required List<ExitStep> steps,
|
||||
Duration stepTimeout = const Duration(seconds: 8),
|
||||
Duration farewell = const Duration(milliseconds: 1000),
|
||||
}) =>
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _ExitProgress(lastWork: lastWork, steps: steps, stepTimeout: stepTimeout, farewell: farewell),
|
||||
);
|
||||
|
||||
class _ExitProgress extends StatefulWidget {
|
||||
final String lastWork;
|
||||
final List<ExitStep> steps;
|
||||
final Duration stepTimeout, farewell;
|
||||
const _ExitProgress(
|
||||
{required this.lastWork, required this.steps, required this.stepTimeout, required this.farewell});
|
||||
|
||||
@override
|
||||
State<_ExitProgress> createState() => _ExitProgressState();
|
||||
}
|
||||
|
||||
class _ExitProgressState extends State<_ExitProgress> {
|
||||
int _done = 0;
|
||||
bool _bye = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_run();
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
// 첫 화면 ("종료 중입니다.") 이 보인 뒤 시작
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
for (final s in widget.steps) {
|
||||
// 너무 빨리 지나가 읽을 수 없지 않도록 단계마다 잠깐은 보여 준다
|
||||
final shown = Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
try {
|
||||
await s.run().timeout(widget.stepTimeout);
|
||||
} catch (_) {}
|
||||
await shown;
|
||||
if (!mounted) return;
|
||||
setState(() => _done++);
|
||||
}
|
||||
setState(() => _bye = true);
|
||||
await Future<void>.delayed(widget.farewell);
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Text(_bye ? 'Have You Good Time' : '종료 중입니다.'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('바로 전 작업: ${widget.lastWork}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 12),
|
||||
for (final (i, s) in widget.steps.indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: i < _done
|
||||
? const Icon(Icons.check_circle, size: 18, color: JjColors.success)
|
||||
: i == _done
|
||||
? const CircularProgressIndicator(strokeWidth: 2)
|
||||
: const Icon(Icons.circle_outlined, size: 18, color: JjColors.textDim),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(s.label,
|
||||
style: TextStyle(fontSize: 13, color: i <= _done ? JjColors.text : JjColors.textDim)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -346,6 +346,30 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('탐색기에서 동영상을 열 때'),
|
||||
subtitle: const Text('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.openFileAction,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'play', child: Text('바로 재생 (기본)')),
|
||||
DropdownMenuItem(value: 'add', child: Text('편집 목록에 추가 (MKV 만들기 대상)')),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('탐색기에서 연 동영상을 재생할 창'),
|
||||
subtitle: const Text('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.openFileWindow,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'same', child: Text('켜져 있는 창에서 (기본)')),
|
||||
DropdownMenuItem(value: 'new', child: Text('새 창에서')),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!),
|
||||
),
|
||||
),
|
||||
_ExternalPlayers(c: c),
|
||||
_ContextMenuTile(c: c),
|
||||
_section('인터넷 자막 (OpenSubtitles.com)'),
|
||||
|
|
@ -438,6 +462,22 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
),
|
||||
),
|
||||
_section('실행'),
|
||||
ListTile(
|
||||
title: const Text('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면'),
|
||||
subtitle: Text('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. ${s.showHotkey} 또는 트레이 아이콘으로 다시 엽니다.\n'
|
||||
|
||||
'완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)'),
|
||||
isThreeLine: true,
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.closeAction,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'background', child: Text('백그라운드로 계속 (기본)')),
|
||||
DropdownMenuItem(value: 'quit', child: Text('다운로드 · 변환도 모두 종료 (묻지 않음)')),
|
||||
DropdownMenuItem(value: 'ask', child: Text('매번 고르기')),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.closeAction = v!),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.minimizeToTray,
|
||||
onChanged: (v) => c.updateSettings((x) => x.minimizeToTray = v),
|
||||
|
|
|
|||
|
|
@ -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.1.0+4
|
||||
version: 1.1.1+5
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.4
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/playlist.dart';
|
||||
import 'package:jj_mkvmaker/main.dart' show lastWorkText, wantsNewPlayerWindow;
|
||||
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/shell_integration.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/single_instance.dart';
|
||||
import 'package:jj_mkvmaker/services/media_player.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/exit_dialog.dart';
|
||||
import 'package:jj_mkvmaker/ui/player_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
@ -137,6 +140,25 @@ void main() {
|
|||
expect(LaunchRequest.parse([r'D:\a.mp4']).action, LaunchAction.add);
|
||||
expect(LaunchRequest.parse(['--subtitle', 'x']).toArgs(), ['--subtitle', 'x']);
|
||||
});
|
||||
|
||||
test('탐색기에서 연 동영상: 새 재생 창 여부', () {
|
||||
final open = LaunchRequest.parse([r'D:.mkv']);
|
||||
final play = LaunchRequest.parse(['--play', r'D:.mkv']);
|
||||
final sub = LaunchRequest.parse(['--subtitle', r'D:.mkv']);
|
||||
final s = AppSettings();
|
||||
expect([s.openFileAction, s.openFileWindow], ['play', 'same']);
|
||||
expect(wantsNewPlayerWindow(open, s), isFalse); // 기본: 켜져 있는 창에서
|
||||
s.openFileWindow = 'new';
|
||||
expect(wantsNewPlayerWindow(open, s), isTrue);
|
||||
expect(wantsNewPlayerWindow(play, s), isTrue);
|
||||
expect(wantsNewPlayerWindow(sub, s), isFalse); // 자막 만들기는 원래 창
|
||||
expect(wantsNewPlayerWindow(LaunchRequest.parse(const []), s), isFalse);
|
||||
s.openFileAction = 'add';
|
||||
expect(wantsNewPlayerWindow(open, s), isFalse); // 목록에 추가는 원래 창
|
||||
expect(wantsNewPlayerWindow(play, s), isTrue);
|
||||
final back = AppSettings.fromJson(s.toJson());
|
||||
expect([back.openFileAction, back.openFileWindow], ['add', 'new']);
|
||||
});
|
||||
});
|
||||
|
||||
test('중복 실행 방지: 나중 실행은 넘기고, 짧은 시간의 요청은 하나로', () async {
|
||||
|
|
@ -230,4 +252,123 @@ void main() {
|
|||
await tester.pumpWidget(const SizedBox());
|
||||
expect(pl.calls.last, 'dispose');
|
||||
});
|
||||
|
||||
test('동영상 목록: 저장 → 다시 켜면 그대로, 창 두 개가 서로 맞춤 (추가 · 삭제)', () async {
|
||||
final dir = Directory.systemTemp.createTempSync('jj_list_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final list = p.join(dir.path, 'videos.json');
|
||||
Directory(p.join(dir.path, 'jj_mkv')).createSync();
|
||||
final a = p.join(dir.path, 'a.mp4'), b = p.join(dir.path, 'jj_mkv', 'b.mkv');
|
||||
for (final f in [a, b]) {
|
||||
File(f).writeAsStringSync('x');
|
||||
}
|
||||
AppController make() =>
|
||||
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
|
||||
final w1 = make();
|
||||
await w1.shareVideoList(list);
|
||||
await w1.addVideos([a]);
|
||||
// 탐색기에서 직접 연 파일은 jj_mkv 안이어도 들어간다 (폴더째 넣을 때는 제외)
|
||||
await w1.addVideos([b]);
|
||||
expect(w1.videos, hasLength(1));
|
||||
await w1.addVideos([b], allowOutputFolder: true);
|
||||
expect(w1.videos.map((v) => v.fileName), ['a.mp4', 'b.mkv']);
|
||||
|
||||
// 새 창: 같은 목록으로 시작
|
||||
final w2 = make();
|
||||
await w2.shareVideoList(list);
|
||||
expect(w2.videos.map((v) => v.fileName), ['a.mp4', 'b.mkv']);
|
||||
|
||||
// 새 창에서 삭제 → 원래 창에서도 삭제
|
||||
w2.removeVideo(w2.videos.first);
|
||||
await w1.syncVideoList();
|
||||
expect(w1.videos.map((v) => v.fileName), ['b.mkv']);
|
||||
// 원래 창에서 추가 → 새 창에도
|
||||
await w1.addVideos([a]);
|
||||
await w2.syncVideoList();
|
||||
expect(w2.videos.map((v) => v.fileName), ['b.mkv', 'a.mp4']);
|
||||
|
||||
// 없어진 파일은 다시 켤 때 빠진다
|
||||
File(a).deleteSync();
|
||||
final w3 = make();
|
||||
await w3.shareVideoList(list);
|
||||
expect(w3.videos.map((v) => v.fileName), ['b.mkv']);
|
||||
for (final w in [w1, w2, w3]) {
|
||||
w.stopVideoListShare();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('종료 진행 창: 종료 중입니다 → 단계 → Have You Good Time', (tester) async {
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
expect(lastWorkText(c), '없음');
|
||||
final ran = <String>[];
|
||||
final gate = Completer<void>();
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
|
||||
ctx = x;
|
||||
return const SizedBox();
|
||||
})));
|
||||
var closed = false;
|
||||
unawaited(showExitProgress(ctx, lastWork: 'MKV 만들기', steps: [
|
||||
ExitStep('다운로드 정리', () async => ran.add('dl')),
|
||||
ExitStep('쓰레드 확인', () async {
|
||||
await gate.future;
|
||||
ran.add('thread');
|
||||
}),
|
||||
ExitStep('실패해도 계속', () async => throw StateError('x')),
|
||||
ExitStep('창 종료', () async => ran.add('close')),
|
||||
]).then((_) => closed = true));
|
||||
await tester.pump();
|
||||
expect(find.text('종료 중입니다.'), findsOneWidget);
|
||||
expect(find.text('바로 전 작업: MKV 만들기'), findsOneWidget);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(ran, ['dl']); // 쓰레드 확인에서 기다리는 중
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
gate.complete();
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(ran, ['dl', 'thread', 'close']);
|
||||
expect(find.text('Have You Good Time'), findsOneWidget);
|
||||
expect(closed, isFalse);
|
||||
await tester.pump(const Duration(milliseconds: 1100));
|
||||
await tester.pump();
|
||||
expect(closed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('종료를 누르면: 설정 기본값 · 고르기 창 (백그라운드로 / 모두 종료 / 묻지 않기)', (tester) async {
|
||||
expect(AppSettings().closeAction, 'background');
|
||||
expect(AppSettings.fromJson((AppSettings()..closeAction = 'quit').toJson()).closeAction, 'quit');
|
||||
expect(AppSettings.fromJson({'closeAction': 'x'}).closeAction, 'background');
|
||||
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
|
||||
ctx = x;
|
||||
return const SizedBox();
|
||||
})));
|
||||
Future<(String, bool)?> open() => showCloseChoice(ctx, running: '다운로드 2개', hotkey: 'Ctrl+Shift+X');
|
||||
|
||||
var r = open();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('지금: 다운로드 2개'), findsOneWidget);
|
||||
expect(find.textContaining('Ctrl+Shift+X'), findsOneWidget);
|
||||
await tester.tap(find.text('백그라운드로'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await r, ('background', false));
|
||||
|
||||
r = open();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(Checkbox));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('모두 종료'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await r, ('quit', true));
|
||||
|
||||
r = open();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('취소'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await r, isNull);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue