v1.1.1: 더블클릭 재생, 창 위치 · 목록 기억, 종료 방식 설정

- 탐색기에서 연 동영상: 바로 재생 / 목록에 추가, 켜져 있는 창 / 새 창 (설정)
  jj_mkv 폴더의 파일을 열면 창만 뜨던 문제 수정
- 재생한 동영상을 목록에 추가, 목록을 videos.json 에 저장하고 창끼리 맞춤
- 창 위치 · 크기 · 최대화 기억 (window.json)
- 제목 표시줄에 버전 표시
- 종료를 누르면: 백그라운드로 계속 (기본) / 모두 종료 / 매번 고르기
  트레이 "종료" 는 항상 종료
- 종료 진행 창 (단계 표시, 단계별 시간 제한)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
old-history-local
RyuJongJin 2026-09-30 18:04:18 +09:00
parent 960a695c7e
commit 408cf3101a
11 changed files with 692 additions and 20 deletions

View File

@ -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 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |

View File

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

View File

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

View File

@ -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 ctx = navigatorKey.currentContext;
if (ctx != null && !await confirmExit(ctx, downloads)) return false;
await downloads.shutdown();
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 || !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,

View File

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

View File

@ -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; // 다른 프로그램이 포트를 쓰는 경우: 넘기지 못했으므로 그냥 실행
}

View File

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

155
lib/ui/exit_dialog.dart Normal file
View File

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

View File

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

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

View File

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