v2026.09.30_002: 모든 화면 끌어다 놓기, 화면 크기 조절, 브라우저 COM 오류 수정

- 끌어다 놓기를 앱 전체에서 받아 동영상 목록에 추가 (안내 테두리 · 결과 알림)
- 화면 크기 조절 버튼 (70~160%) 과 기본 화면 크기 설정
- MKV 화면: 폴더 열기 버튼, 동영상 목록 오른쪽 클릭 메뉴
- 위쪽 막대가 창 너비에 맞춰 줄어듦 (설정 · 종료 버튼이 밀려나지 않게)
- WebView2 환경을 만들기 전에 COM 을 다시 준비, 실패 결과를 붙들지 않음,
  3초마다 COM 상태 확인 (풀려 있으면 다시 준비하고 작업 기록에 남김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main v2026.09.30_002
RyuJongJin 2026-09-30 22:28:00 +09:00
parent 1ba49ffa8d
commit 996dd44cb5
17 changed files with 1063 additions and 247 deletions

View File

@ -27,7 +27,8 @@
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
| **상태 표시** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 환경 설정 · 종료 버튼 |
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
| **끌어다 놓기 · 오른쪽 클릭** | 어느 화면에서든 동영상 · 폴더를 놓으면 목록에 추가. 목록에서 오른쪽 클릭: 폴더 열기 · 재생 · 자막 만들기 · 자막 찾기 · 삭제 |
## 실행

View File

@ -0,0 +1,51 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/platform/windows/com_guard.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
/// 실제 Edge(WebView2): 다른 구성 요소가 COM 준비 상태를 풀어 버린 상황을 만들고
/// (브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨던 문제) 그래도 브라우저 환경이 만들어지는지
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('COM 이 풀려 있어도 브라우저 환경을 만든다', (tester) async {
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
// 1. 화면을 그리는 스레드 = 실행 파일이 COM 을 준비한 스레드인지 (그래야 여기서 다시 준비한 것이 통한다)
final first = ComGuard.ensure();
// ignore: avoid_print
print('RESULT 처음 확인: $first (1 = 이미 준비되어 있음 → 같은 스레드)');
expect(first, 1);
ComGuard.releaseForTest(); // 방금 올린 만큼 내림
// 2. 준비 상태를 끝까지 푼다 (문제 상황). 여러 구성 요소가 겹쳐 준비해 두므로 넉넉히 여러 번.
for (var i = 0; i < 8; i++) {
ComGuard.releaseForTest();
}
final dir = Directory.systemTemp.createTempSync('jj_com_').path;
final raw = await tester.runAsync<Object?>(() async {
try {
await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw'));
return null;
} catch (e) {
return e;
}
});
// ignore: avoid_print
print('RESULT 풀린 상태에서 그냥 만들기: ${raw ?? '성공'}');
expect('$raw', contains('Cannot create WebViewEnvironment'));
// 3. 앱의 방식 (만들기 전에 다시 준비) 으로는 만들어진다
for (var i = 0; i < 8; i++) {
ComGuard.releaseForTest();
}
final env = await tester.runAsync(() => browserEnvironment(dir));
// ignore: avoid_print
print('RESULT 앱 방식으로 만들기: ${env == null ? '실패' : '성공'}');
expect(env, isNotNull);
}, timeout: const Timeout(Duration(minutes: 2)));
}

View File

@ -1100,6 +1100,9 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
/// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때)
void note(String msg) => _log(msg);
void _log(String msg) {
final t = DateTime.now();
String two(int n) => n.toString().padLeft(2, '0');

View File

@ -63,6 +63,14 @@ class AppSettings {
/// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl])
String startScreen = 'home';
/// 화면 (글자 · 버튼) 크기 배율. [uiScale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈),
/// [uiScaleDefault] 는 기본 크기 (환경 설정에서 정함, 가운데 숫자를 누르면 이 크기로 돌아감)
double uiScale = 1.0;
double uiScaleDefault = 1.0;
static const double uiScaleMin = 0.7, uiScaleMax = 1.6;
static double clampUiScale(num? v) =>
((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20;
/// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가
String openFileAction = 'play';
@ -123,6 +131,8 @@ class AppSettings {
'maxParallelJobs': maxParallelJobs,
'showLog': showLog,
'startScreen': startScreen,
'uiScale': uiScale,
'uiScaleDefault': uiScaleDefault,
'openFileAction': openFileAction,
'openFileWindow': openFileWindow,
'homeUrl': homeUrl,
@ -168,6 +178,8 @@ class AppSettings {
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
..showLog = j['showLog'] as bool? ?? true
..startScreen = j['startScreen'] as String? ?? 'home'
..uiScale = clampUiScale(j['uiScale'] as num?)
..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?)
..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/'

View File

@ -13,6 +13,7 @@ import 'app/download_manager.dart';
import 'app/settings.dart';
import 'core/app_update.dart';
import 'core/playlist.dart';
import 'platform/windows/com_guard.dart';
import 'platform/windows/desktop_shell.dart';
import 'platform/windows/exit_trace.dart';
import 'platform/windows/single_instance.dart';
@ -20,6 +21,7 @@ import 'platform/windows/window_memory.dart';
import 'services/platform_services.dart';
import 'ui/ai_dialog.dart';
import 'ui/app_actions.dart';
import 'ui/app_drop.dart';
import 'ui/browser_page.dart';
import 'ui/downloads_page.dart';
import 'ui/exit_dialog.dart';
@ -181,6 +183,13 @@ Future<void> main(List<String> args) async {
await controller.updateSettings((x) => x.addFinishedDownloads = on);
downloads.refresh();
};
// COM 준비 상태 지킴이: 풀려 있으면 (앱 안 브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨는 원인)
// 다시 준비하고 작업 기록에 남긴다. 바로 앞의 기록을 보면 무엇을 한 뒤에 풀렸는지 알 수 있다.
Timer.periodic(const Duration(seconds: 3), (_) {
if (ComGuard.repairIfNeeded()) {
controller.note('⚠ Windows COM 준비 상태가 풀려 있어 다시 준비했습니다 (브라우저 · 끌어다 놓기에 필요)');
}
});
downloads.addToEditList = controller.addDownloaded;
unawaited(downloads.startClipboardWatch());
@ -316,7 +325,11 @@ class JjCapCutApp extends StatelessWidget {
theme: buildTheme(),
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
builder: (context, child) => AppScope(controller: controller, onExit: onExit, child: child!),
// 모든 화면에: 환경 설정 · 종료 버튼이 쓸 것 + 끌어다 놓은 동영상을 목록에 추가
builder: (context, child) => AppScope(
controller: controller,
onExit: onExit,
child: AppDropArea(c: controller, child: UiScaler(c: controller, child: child!))),
home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
);
}

View File

@ -0,0 +1,42 @@
import 'dart:ffi';
import 'dart:io';
typedef _CoInitC = Int32 Function(Pointer<Void> reserved, Uint32 mode);
typedef _CoInitD = int Function(Pointer<Void> reserved, int mode);
typedef _CoUninitC = Void Function();
typedef _CoUninitD = void Function();
/// Windows COM 준비 상태를 다루는 도우미.
///
/// 앱 안 브라우저 (Edge WebView2) 는 화면을 그리는 스레드에 COM 이 준비되어 있어야 만들어진다.
/// 실행 파일이 시작할 때 준비해 두지만, 다른 구성 요소가 그것을 풀어 버리면
/// "CoInitialize 가 호출되지 않았습니다" 로 브라우저가 뜨지 않는다 → 만들기 직전에 다시 준비한다.
class ComGuard {
static final DynamicLibrary _ole = DynamicLibrary.open('ole32.dll');
static final _CoInitD _init = _ole.lookupFunction<_CoInitC, _CoInitD>('CoInitializeEx');
static final _CoUninitD _uninit = _ole.lookupFunction<_CoUninitC, _CoUninitD>('CoUninitialize');
static const _apartmentThreaded = 0x2;
/// 이 스레드에 COM 을 준비한다 (이미 되어 있으면 그대로). 돌려주는 값은 Windows 결과 코드:
/// 0 = 새로 준비함 (풀려 있었음), 1 = 이미 준비되어 있었음, 음수 = 다른 방식으로 이미 준비됨 · 실패
static int ensure() {
if (!Platform.isWindows) return 1;
try {
return _init(nullptr, _apartmentThreaded);
} catch (_) {
return -1;
}
}
/// 준비 상태를 확인하고, 풀려 있었으면 다시 준비한다. 다시 준비했으면 true.
/// (이미 준비되어 있었으면 확인하느라 올린 만큼 도로 내려 원래대로 둔다)
static bool repairIfNeeded() {
final hr = ensure();
if (hr == 1) _uninit();
return hr == 0;
}
/// 시험용: 준비 상태를 한 단계 푼다
static void releaseForTest() => _uninit();
}

View File

@ -268,6 +268,20 @@ class DesktopShell with WindowListener implements AppShell {
@override
Future<void> openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser);
/// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더).
@override
Future<void> revealFile(String path) async {
if (File(path).existsSync()) {
await Process.start('explorer.exe', ['/select,', path], mode: ProcessStartMode.detached);
return;
}
var dir = Directory(path).existsSync() ? Directory(path) : File(path).parent;
while (!dir.existsSync() && dir.parent.path != dir.path) {
dir = dir.parent;
}
await Process.start('explorer.exe', [dir.path], mode: ProcessStartMode.detached);
}
@override
List<String> installedBrowsers() => [
for (final b in browserExecutables.keys)

View File

@ -51,6 +51,9 @@ abstract class AppShell {
/// 웹 주소를 외부 브라우저로 열기. [browser]: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale'
Future<void> openUrl(String url, {String browser = 'system'});
/// 파일이 있는 폴더를 파일 관리자(탐색기)로 열고 그 파일을 선택해 보여 준다
Future<void> revealFile(String path);
/// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중)
List<String> installedBrowsers();
@ -105,6 +108,8 @@ class NoopShell implements AppShell {
@override
Future<void> openUrl(String url, {String browser = 'system'}) async {}
@override
Future<void> revealFile(String path) async {}
@override
List<String> installedBrowsers() => const [];
@override
Future<List<String>> missingTools() async => const [];

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/settings.dart';
import '../services/system_usage.dart';
import 'settings_page.dart';
import 'theme.dart';
@ -86,6 +87,90 @@ class UsageView extends StatelessWidget {
);
}
/// 화면 크기 조절: [−] [100%] [+]. 10% 씩 바꾸고, 가운데 숫자를 누르면 환경 설정의 기본 크기로.
class ScaleButtons extends StatelessWidget {
final AppController c;
const ScaleButtons({super.key, required this.c});
void _set(double v) => c.updateSettings((s) => s.uiScale = AppSettings.clampUiScale(v));
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: c,
builder: (context, _) {
final s = c.settings.uiScale;
Widget btn(IconData icon, String tip, VoidCallback? f) => SizedBox(
width: 26,
height: 32,
child: IconButton(
tooltip: tip,
padding: EdgeInsets.zero,
iconSize: 16,
icon: Icon(icon),
onPressed: f,
),
);
return Padding(
padding: const EdgeInsets.only(left: 6),
child: Row(mainAxisSize: MainAxisSize.min, children: [
btn(Icons.remove, '화면 작게', s <= AppSettings.uiScaleMin + 0.001 ? null : () => _set(s - 0.1)),
Tooltip(
message: '기본 크기 (${(c.settings.uiScaleDefault * 100).round()}%) 로 · 기본 크기는 환경 설정 > 화면 에서',
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => _set(c.settings.uiScaleDefault),
child: SizedBox(
width: 40,
height: 28,
child: Center(
child: Text('${(s * 100).round()}%',
style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', fontWeight: FontWeight.w600)),
),
),
),
),
btn(Icons.add, '화면 크게', s >= AppSettings.uiScaleMax - 0.001 ? null : () => _set(s + 0.1)),
]),
);
},
);
}
/// 화면 전체를 [AppSettings.uiScale] 배로 그린다 (안쪽은 그만큼 작은 · 큰 화면이라고 여기고 배치).
/// 앱의 builder 에서 한 번 감싼다 - 대화 상자 · 메뉴 · 안내 글까지 모두 같은 배율.
class UiScaler extends StatelessWidget {
final AppController c;
final Widget child;
const UiScaler({super.key, required this.c, required this.child});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: c,
builder: (context, _) {
final s = c.settings.uiScale;
if ((s - 1).abs() < 0.001) return child;
return LayoutBuilder(builder: (context, box) {
final size = box.biggest / s;
final mq = MediaQuery.of(context);
return ClipRect(
child: OverflowBox(
alignment: Alignment.topLeft,
minWidth: size.width,
maxWidth: size.width,
minHeight: size.height,
maxHeight: size.height,
child: Transform.scale(
scale: s,
alignment: Alignment.topLeft,
child: MediaQuery(data: mq.copyWith(size: size), child: child),
),
),
);
});
},
);
}
/// 환경 설정 · 종료 버튼. 모든 화면의 위쪽 막대 맨 오른쪽에 같은 크기로 놓는다.
class AppActions extends StatelessWidget {
/// 직접 주지 않으면 [AppScope] 의 것을 쓴다
@ -95,7 +180,10 @@ class AppActions extends StatelessWidget {
/// 환경 설정 화면 자신: 설정 버튼을 "지금 여기" 로 표시하고 누를 수 없게
final bool onSettingsPage;
const AppActions({super.key, this.c, this.onExit, this.onSettingsPage = false});
/// 창이 좁을 때 CPU · MEM · DISK 를 숨긴다 (버튼 자리는 그대로)
final bool showUsage;
const AppActions({super.key, this.c, this.onExit, this.onSettingsPage = false, this.showUsage = true});
@override
Widget build(BuildContext context) {
@ -106,7 +194,9 @@ class AppActions extends StatelessWidget {
final usage = controller?.usage;
return Row(mainAxisSize: MainAxisSize.min, children: [
// CPU · MEM: 변환 · AI 작업이 PC 를 얼마나 쓰는지 (PC 전체 기준)
if (usage != null) UsageView(usage: usage),
if (usage != null && showUsage) UsageView(usage: usage),
// 화면 크기: − 100% +
if (controller != null) ScaleButtons(c: controller),
const SizedBox(width: 4),
if (controller != null)
IconButton(

89
lib/ui/app_drop.dart Normal file
View File

@ -0,0 +1,89 @@
import 'dart:io';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../core/playlist.dart';
import 'theme.dart';
/// 끌어다 놓은 파일 · 폴더에서 동영상을 골라 MKV 만들기의 동영상 목록에 넣는다.
/// 직접 놓은 동영상 파일은 jj_mkv 폴더 안에 있어도 넣고, 폴더째 놓으면 그 안의 jj_ 출력 폴더는 건너뛴다.
/// 돌려주는 값: (새로 넣은 개수, 찾은 동영상 개수)
Future<(int, int)> addDroppedVideos(AppController c, List<String> paths) async {
final direct = [
for (final path in paths)
if (isVideoFile(path) && !await FileSystemEntity.isDirectory(path)) path,
];
final found = await collectVideos(paths,
isDirectory: (x) => FileSystemEntity.isDirectory(x),
listDir: (x) async => Directory(x).list().map((e) => e.path).toList());
final all = {...direct, ...found}.toList();
final before = c.videos.length;
await c.addVideos(direct, allowOutputFolder: true);
await c.addVideos(found);
final added = c.videos.skip(before).toList();
if (added.isNotEmpty) c.select(added.first);
return (added.length, all.length);
}
/// 앱 전체를 감싸는 끌어다 놓기 영역: 어느 화면에서 놓아도 MKV 만들기의 동영상 목록에 추가한다.
/// 끌고 들어오면 화면에 안내를 띄우고, 놓으면 결과를 알려 준다.
class AppDropArea extends StatefulWidget {
final AppController c;
final Widget child;
const AppDropArea({super.key, required this.c, required this.child});
@override
State<AppDropArea> createState() => _AppDropAreaState();
}
class _AppDropAreaState extends State<AppDropArea> {
bool _over = false;
Future<void> _dropped(List<String> paths) async {
setState(() => _over = false);
final m = ScaffoldMessenger.maybeOf(context);
final (added, found) = await addDroppedVideos(widget.c, paths);
m?.clearSnackBars();
m?.showSnackBar(SnackBar(
content: Text(found == 0
? '끌어다 놓은 항목에 동영상이 없습니다.'
: added == 0
? '이미 동영상 목록에 있습니다 ($found개).'
: 'MKV 만들기의 동영상 목록에 $added개를 추가했습니다.'
'${added < found ? ' (${found - added}개는 이미 있음)' : ''}')));
}
@override
Widget build(BuildContext context) => DropTarget(
onDragEntered: (_) => setState(() => _over = true),
onDragExited: (_) => setState(() => _over = false),
onDragDone: (d) => _dropped(d.files.map((f) => f.path).toList()),
child: Stack(children: [
widget.child,
if (_over)
Positioned.fill(
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: JjColors.accent.withValues(alpha: 0.12),
border: Border.all(color: JjColors.accent, width: 3),
),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
decoration: BoxDecoration(color: JjColors.panel, borderRadius: BorderRadius.circular(10)),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.playlist_add, color: JjColors.accent),
SizedBox(width: 10),
Text('여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다',
style: TextStyle(fontSize: 15, color: JjColors.text, decoration: TextDecoration.none)),
]),
),
),
),
),
]),
);
}

View File

@ -10,6 +10,7 @@ import '../app/download_manager.dart';
import '../core/bookmarks.dart';
import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies;
import '../core/web_address.dart';
import '../platform/windows/com_guard.dart';
import 'app_actions.dart';
import 'downloads_page.dart';
import 'theme.dart';
@ -341,9 +342,26 @@ Future<WebViewEnvironment?>? _env;
/// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (한 번만 만들어 재사용)
Future<WebViewEnvironment?> browserEnvironment(String dir) =>
_env ??= Platform.isWindows
? WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir))
: Future.value(null);
_env ??= Platform.isWindows ? _createEnvironment(dir) : Future.value(null);
Future<WebViewEnvironment?> _createEnvironment(String dir) async {
Future<WebViewEnvironment> create() =>
WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir));
try {
// COM 이 풀려 있으면 "CoInitialize 가 호출되지 않았습니다" 로 실패한다 → 만들기 전에 다시 준비
ComGuard.ensure();
try {
return await create();
} catch (_) {
ComGuard.ensure();
return await create();
}
} catch (_) {
// 실패한 결과를 붙들고 있지 않는다: 브라우저를 다시 열면 새로 시도
_env = null;
rethrow;
}
}
class _EdgeViewState extends State<_EdgeView> {
@override

View File

@ -1,6 +1,3 @@
import 'dart:io';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
@ -11,7 +8,6 @@ import '../core/encode_options.dart';
import '../core/languages.dart';
import '../core/models.dart';
import '../core/output_paths.dart';
import '../core/playlist.dart';
import 'ai_dialog.dart';
import 'app_actions.dart';
import 'browser_page.dart';
@ -42,21 +38,8 @@ class HomePage extends StatelessWidget {
return ListenableBuilder(
listenable: c,
builder: (context, _) => Scaffold(
// 탐색기에서 동영상 · 폴더를 끌어다 놓으면 동영상만 추가
body: DropTarget(
onDragDone: (d) async {
final videos = await collectVideos(d.files.map((f) => f.path),
isDirectory: (x) => FileSystemEntity.isDirectory(x),
listDir: (x) async => Directory(x).list().map((e) => e.path).toList());
if (videos.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('끌어다 놓은 항목에 동영상이 없습니다.')));
}
return;
}
await c.addVideos(videos);
},
// 끌어다 놓기는 앱 전체에서 받는다 (ui/app_drop.dart 의 AppDropArea - 어느 화면에서 놓아도 이 목록에 추가)
body: SizedBox(
child: Column(
children: [
_TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
@ -119,6 +102,48 @@ class _TopBar extends StatelessWidget {
final batch = c.batchTargets;
final canBatch = batch.isNotEmpty && c.ffmpegVersion != null;
final count = c.checked.isEmpty ? '' : ' (${c.checked.length})';
// 창 너비에 맞춰 차례로 줄인다: 제목 글 → 왼쪽 버튼을 아이콘만 → 재생 · 브라우저를 아이콘만 → CPU · MEM 숨김.
// (오른쪽의 화면 크기 · 환경 설정 · 종료 버튼은 어떤 너비에서도 밀려나지 않게)
return LayoutBuilder(builder: (context, box) {
final hasAi = c.aiAvailable;
final hasPlayer = c.services.createMediaPlayer != null;
var title = true, leftText = true, rightText = true, usage = c.usage != null, jobText = true;
// 각 부분의 너비 (실제 화면에서 잰 값)
double need() =>
16 + appBarRightPadding + 36 + (title ? 126 : 0) + 24 +
(leftText ? 153 + (hasAi ? 161 + 298 : 0) : 40 + (hasAi ? 96 : 0)) + 8 +
(c.busy ? (jobText ? 350 : 60) : 0) +
(hasPlayer ? (rightText ? 201 : 48) : 0) +
166 + (c.busy && jobText ? 60 : 0) +
(bookmarks != null ? (rightText ? 138 : 48) : 0) +
(downloads != null ? 162 : 0) +
(usage ? 220 : 0) + 96 + 92 +
(c.checked.isEmpty ? 0 : 3 * 26) +
8; // 여유
final w = box.maxWidth;
if (need() > w) title = false;
if (need() > w) jobText = false;
if (need() > w) leftText = false;
if (need() > w) rightText = false;
if (need() > w) usage = false;
// 글이 있는 버튼, 또는 (좁을 때) 아이콘만 있는 버튼
Widget action(IconData icon, String label, VoidCallback? onPressed,
{required bool text, required String tip, Color? color}) {
return Tooltip(
message: tip,
child: text
? OutlinedButton.icon(
onPressed: onPressed, icon: Icon(icon, size: 18, color: color), label: Text(label))
: IconButton.outlined(
onPressed: onPressed,
icon: Icon(icon, size: 20, color: onPressed == null ? null : color),
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: const BorderSide(color: JjColors.border))),
);
}
return Container(
height: appBarHeight,
color: JjColors.panel,
@ -128,97 +153,82 @@ class _TopBar extends StatelessWidget {
// 프로그램 아이콘 (tool/make_icon.py 로 생성)
Image.asset('assets/icon/app_icon_256.png', width: 28, height: 28, filterQuality: FilterQuality.medium),
const SizedBox(width: 8),
const Text('JJ_MKVMaker',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
if (title) const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
const SizedBox(width: 24),
// 왼쪽 묶음: 창이 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 버튼이 밀려나지 않도록)
// 왼쪽 묶음: 그래도 모자라면 옆으로 밀어 볼 수 있게
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: [
OutlinedButton.icon(
onPressed: c.pickVideos,
icon: const Icon(Icons.add, size: 18),
label: const Text('동영상 추가'),
),
if (c.aiAvailable) ...[
action(Icons.add, '동영상 추가', c.pickVideos,
text: leftText, tip: '동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'),
if (hasAi) ...[
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'),
),
),
action(Icons.auto_awesome, '자막 만들기$count',
canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null,
text: leftText, tip: '자막 만들기: ${_batchHint(c, 'AI 자막을 만듭니다')}'),
const SizedBox(width: 8),
Tooltip(
message: _batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다'),
child: OutlinedButton.icon(
onPressed: canBatch
action(
Icons.auto_mode,
'자막 만들기 & MKV 만들기$count',
canBatch
? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true)
: null,
icon: const Icon(Icons.auto_mode, size: 18),
label: Text('자막 만들기 & MKV 만들기$count'),
),
),
text: leftText,
tip: '자막 만들기 & MKV 만들기: ${_batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다')}'),
],
]),
),
),
const SizedBox(width: 8),
if (c.busy) ...[
JobIndicator(c: c),
JobIndicator(c: c, compact: !jobText, iconOnly: !jobText),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: c.cancel,
icon: const Icon(Icons.stop, size: 18, color: JjColors.danger),
label: const Text('취소'),
),
action(Icons.stop, '취소', c.cancel, text: jobText, tip: '작업 취소', color: JjColors.danger),
const SizedBox(width: 8),
],
// 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개)
if (c.services.createMediaPlayer != null) ...[
Tooltip(
message: c.checked.isEmpty
? '지금 보고 있는 동영상을 재생합니다 (여러 개는 목록에서 체크)'
: '체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서 재생합니다',
child: OutlinedButton.icon(
onPressed: batch.isEmpty
if (hasPlayer) ...[
action(
Icons.playlist_play,
'선택한 파일 재생$count',
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'),
),
),
text: rightText,
tip: c.checked.isEmpty
? '선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)'
: '선택한 파일 재생: 체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서'),
const SizedBox(width: 8),
],
FilledButton.icon(
// 체크한 동영상이 있으면 그것만, 없으면 목록 전체
onPressed: !canBuild ? null : (c.checked.isEmpty ? c.buildAll : () => c.buildVideos(batch)),
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
label: Text('MKV 만들기$count${c.busy ? ' (대기열)' : ''}'),
label: Text('MKV 만들기$count${c.busy && jobText ? ' (대기열)' : ''}'),
),
if (bookmarks != null) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => Navigator.push(
action(
Icons.public,
'브라우저',
() => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks!))),
icon: const Icon(Icons.public, size: 18),
label: const Text('브라우저'),
),
text: rightText,
tip: '웹 브라우저'),
],
if (downloads != null) ...[
const SizedBox(width: 12),
_DownloadBox(d: downloads!),
],
// 환경 설정 · 종료: 모든 화면에서 같은 자리
AppActions(c: c, onExit: onExit),
// 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리
AppActions(c: c, onExit: onExit, showUsage: usage),
],
),
);
});
}
}
@ -434,6 +444,55 @@ class _VideoTile extends StatelessWidget {
final VideoItem v;
const _VideoTile({required this.c, required this.v});
Future<void> _menu(BuildContext context, Offset global) async {
c.select(v);
final busy = v.status == JobStatus.running;
PopupMenuItem<String> item(String id, IconData icon, String label, {bool enabled = true}) => PopupMenuItem(
value: id,
enabled: enabled,
height: 36,
child: Row(children: [
Icon(icon, size: 18, color: enabled ? JjColors.text : JjColors.textDim),
const SizedBox(width: 10),
Text(label, style: const TextStyle(fontSize: 13)),
]),
);
// 화면 크기 배율이 걸려 있어도 누른 자리에 뜨도록 메뉴가 그려질 곳 기준으로 바꾼다
final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox;
final at = overlay.globalToLocal(global);
final pick = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, overlay.size.width - at.dx, overlay.size.height - at.dy),
items: [
item('folder', Icons.folder_open, '대상 폴더 열기'),
if (c.services.createMediaPlayer != null) item('play', Icons.play_arrow, '재생'),
const PopupMenuDivider(),
if (c.aiAvailable)
item('ai', Icons.auto_awesome, 'AI 자막 만들기', enabled: !busy && c.ffmpegVersion != null),
if (c.subtitleProvider != null)
item('search', Icons.travel_explore, '인터넷 자막 찾기', enabled: !busy),
item('subtitle', Icons.subtitles_outlined, '자막 파일 추가', enabled: !busy),
const PopupMenuDivider(),
item('remove', Icons.close, '삭제 (목록에서 제거)', enabled: !busy),
],
);
if (pick == null || !context.mounted) return;
switch (pick) {
case 'folder':
await c.services.shell.revealFile(v.path);
case 'play':
await playFiles(context, c, [v.path]);
case 'ai':
await showAiDialog(context, c, v, targets: [v]);
case 'search':
await showSubtitleSearch(context, c, v);
case 'subtitle':
await c.pickSubtitlesFor(v);
case 'remove':
c.removeVideo(v);
}
}
@override
Widget build(BuildContext context) {
final isSel = c.selected == v;
@ -441,6 +500,8 @@ class _VideoTile extends StatelessWidget {
return InkWell(
onTap: () => c.select(v),
onDoubleTap: () => playFiles(context, c, [v.path]),
// 오른쪽 클릭: 이 동영상으로 할 수 있는 일
onSecondaryTapDown: (d) => _menu(context, d.globalPosition),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
padding: const EdgeInsets.fromLTRB(2, 10, 4, 10),
@ -579,6 +640,12 @@ class _VideoDetail extends StatelessWidget {
label: const Text('재생'),
),
],
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => c.services.shell.revealFile(v.path),
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('폴더 열기'),
),
]),
const SizedBox(height: 4),
SelectableText(v.path,

View File

@ -12,6 +12,7 @@ import '../core/subtitle_detector.dart';
import '../services/app_shell.dart';
import 'setup_dialog.dart';
import 'update_dialog.dart';
import '../app/settings.dart';
import 'app_actions.dart';
import 'theme.dart';
@ -260,6 +261,29 @@ class _SettingsPageState extends State<SettingsPage> {
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [
_section('화면'),
ListTile(
title: const Text('기본 화면 크기'),
subtitle: Text('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 '
'(지금 ${(s.uiScale * 100).round()}%)'),
trailing: DropdownButton<double>(
value: s.uiScaleDefault,
items: [
for (var v = AppSettings.uiScaleMin; v <= AppSettings.uiScaleMax + 0.001; v += 0.1)
DropdownMenuItem(
value: AppSettings.clampUiScale(v),
child: Text('${(v * 100).round()}%${(v - 1).abs() < 0.001 ? ' (보통)' : ''}')),
// 5% 단위로 저장된 값이 목록에 없을 때
if (((s.uiScaleDefault * 100).round() % 10) != 0)
DropdownMenuItem(
value: s.uiScaleDefault, child: Text('${(s.uiScaleDefault * 100).round()}%')),
],
// 기본 크기를 바꾸면 지금 크기도 그 크기로
onChanged: (v) => c.updateSettings((x) => x
..uiScaleDefault = v!
..uiScale = v),
),
),
_section('시작 · 웹 브라우저'),
ListTile(
title: const Text('처음 화면'),

View File

@ -29,44 +29,72 @@ class WorkPanel extends StatelessWidget {
listenable: Listenable.merge([c, ?downloads]),
builder: (context, _) => Container(
color: JjColors.panel,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(context),
const Divider(height: 1),
Expanded(
child: ListView(padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), children: [
child: ListView(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
children: [
..._jobs(),
_title('편집 목록 ${c.videos.length}개'),
if (c.videos.isEmpty) const _Dim('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).'),
if (c.videos.isEmpty)
const _Dim('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).'),
for (final v in c.videos) _VideoRow(v: v),
if (downloads != null) ...[
const SizedBox(height: 12),
_title('다운로드 ${downloads!.tasks.length}개',
_title(
'다운로드 ${downloads!.tasks.length}개',
action: TextButton(
onPressed: () => Navigator.push(
context, MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: downloads!))),
child: const Text('전체 목록', style: TextStyle(fontSize: 12)),
)),
if (downloads!.tasks.isEmpty) const _Dim('동영상 페이지에서 [다운로드] 를 누르세요.'),
for (final t in downloads!.tasks.reversed.take(30)) _DownloadRow(c: c, t: t),
],
]),
context,
MaterialPageRoute<void>(
builder: (_) => DownloadsPage(d: downloads!),
),
),
child: const Text(
'전체 목록',
style: TextStyle(fontSize: 12),
),
),
),
if (downloads!.tasks.isEmpty)
const _Dim('동영상 페이지에서 [다운로드] 를 누르세요.'),
for (final t in downloads!.tasks.reversed.take(30))
_DownloadRow(c: c, t: t),
],
],
),
),
],
),
]),
),
);
Widget _header(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row(children: [
child: Row(
children: [
const Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
const Expanded(child: Text('작업 현황', style: TextStyle(fontWeight: FontWeight.w600))),
IconButton(tooltip: '동영상 추가', iconSize: 18, icon: const Icon(Icons.add), onPressed: c.pickVideos),
const Expanded(
child: Text('작업 현황', style: TextStyle(fontWeight: FontWeight.w600)),
),
IconButton(
tooltip: '동영상 추가',
iconSize: 18,
icon: const Icon(Icons.add),
onPressed: c.pickVideos,
),
IconButton(
tooltip: c.busy ? 'MKV 만들기 (대기열에 추가)' : 'MKV 만들기',
iconSize: 18,
icon: const Icon(Icons.play_arrow, color: JjColors.accent),
onPressed: c.videos.isEmpty || c.ffmpegVersion == null ? null : c.buildAll,
onPressed: c.videos.isEmpty || c.ffmpegVersion == null
? null
: c.buildAll,
),
if (c.busy)
IconButton(
@ -81,8 +109,14 @@ class WorkPanel extends StatelessWidget {
icon: const Icon(Icons.open_in_full),
onPressed: onOpenHome,
),
IconButton(tooltip: '작업 현황 닫기', iconSize: 18, icon: const Icon(Icons.close), onPressed: onClose),
]),
IconButton(
tooltip: '작업 현황 닫기',
iconSize: 18,
icon: const Icon(Icons.close),
onPressed: onClose,
),
],
),
);
List<Widget> _jobs() => [
@ -90,21 +124,36 @@ class WorkPanel extends StatelessWidget {
if (!c.busy)
const _Dim('쉬는 중')
else ...[
Text('▶ ${c.currentJob ?? '작업 중'}', style: const TextStyle(fontSize: 12, color: JjColors.accent)),
Text(
'▶ ${c.currentJob ?? '작업 중'}',
style: const TextStyle(fontSize: 12, color: JjColors.accent),
),
for (final (i, j) in c.pendingJobs.indexed)
Text('${i + 1}. $j (대기)', style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
Text(
'${i + 1}. $j (대기)',
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
),
],
const SizedBox(height: 12),
];
Widget _title(String t, {Widget? action}) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(children: [
child: Row(
children: [
Expanded(
child: Text(t,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.textDim))),
child: Text(
t,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: JjColors.textDim,
),
),
),
?action,
]),
],
),
);
}
@ -114,7 +163,10 @@ class _Dim extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(text, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
child: Text(
text,
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
),
);
}
@ -140,24 +192,45 @@ class _VideoRow extends StatelessWidget {
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(v.fileName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
v.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
if (v.status == JobStatus.running)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: LinearProgressIndicator(value: v.progress <= 0 ? null : v.progress, minHeight: 3),
child: LinearProgressIndicator(
value: v.progress <= 0 ? null : v.progress,
minHeight: 3,
),
Text(sub,
),
Text(
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: v.status == JobStatus.failed ? JjColors.danger : color)),
]),
style: TextStyle(
fontSize: 11,
color: v.status == JobStatus.failed
? JjColors.danger
: color,
),
),
],
),
),
],
),
]),
);
}
}
@ -169,29 +242,47 @@ class _DownloadRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final files = t.state == DownloadState.done ? DownloadManager.videoFilesOf(t) : const <String>[];
final files = t.state == DownloadState.done
? DownloadManager.videoFilesOf(t)
: const <String>[];
final inList =
files.isNotEmpty && files.every((f) => c.videos.any((v) => v.path.toLowerCase() == f.toLowerCase()));
files.isNotEmpty &&
files.every(
(f) => c.videos.any((v) => v.path.toLowerCase() == f.toLowerCase()),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
child: Row(
children: [
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 2),
if (t.state != DownloadState.cancelled) DownloadProgressBar(t: t, height: 12),
]),
if (t.state != DownloadState.cancelled)
DownloadProgressBar(t: t, height: 12),
],
),
),
if (files.isNotEmpty)
IconButton(
tooltip: inList ? '편집 목록에 있음' : '편집 목록에 추가',
iconSize: 16,
visualDensity: VisualDensity.compact,
icon: Icon(inList ? Icons.playlist_add_check : Icons.playlist_add,
color: inList ? JjColors.success : JjColors.accent),
icon: Icon(
inList ? Icons.playlist_add_check : Icons.playlist_add,
color: inList ? JjColors.success : JjColors.accent,
),
onPressed: inList ? null : () => c.addDownloaded(files),
),
]),
],
),
);
}
}
@ -200,27 +291,48 @@ class _DownloadRow extends StatelessWidget {
class JobIndicator extends StatelessWidget {
final AppController c;
final bool compact;
const JobIndicator({super.key, required this.c, this.compact = false});
/// 돌아가는 표시만 (글은 마우스를 올리면)
final bool iconOnly;
const JobIndicator({
super.key,
required this.c,
this.compact = false,
this.iconOnly = false,
});
@override
Widget build(BuildContext context) {
if (!c.busy) return const SizedBox();
final running = c.videos.where((v) => v.status == JobStatus.running).toList();
final p = running.isEmpty ? null : running.map((v) => v.progress).reduce((a, b) => a + b) / running.length;
final running = c.videos
.where((v) => v.status == JobStatus.running)
.toList();
final p = running.isEmpty
? null
: running.map((v) => v.progress).reduce((a, b) => a + b) /
running.length;
final wait = c.pendingJobs.length;
return Tooltip(
message: [
'지금: ${c.currentJob ?? ''}',
for (final v in running) ' · ${v.fileName} — ${v.phase ?? ''} ${(v.progress * 100).round()}%',
for (final v in running)
' · ${v.fileName} — ${v.phase ?? ''} ${(v.progress * 100).round()}%',
if (wait > 0) '대기 $wait개:',
for (final j in c.pendingJobs) ' · $j',
].join('\n'),
child: Row(mainAxisSize: MainAxisSize.min, children: [
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, value: p == null || p == 0 ? null : p)),
const SizedBox(width: 6),
child: CircularProgressIndicator(
strokeWidth: 2,
value: p == null || p == 0 ? null : p,
),
),
if (!iconOnly) const SizedBox(width: 6),
if (!iconOnly)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 160 : 240),
child: Text(
@ -230,7 +342,8 @@ class JobIndicator extends StatelessWidget {
style: const TextStyle(fontSize: 12, color: JjColors.accent),
),
),
]),
],
),
);
}
}

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: 2026.9.30+1
version: 2026.9.30+2
environment:
sdk: ^3.13.4

106
test/drop_test.dart Normal file
View File

@ -0,0 +1,106 @@
import 'dart:io';
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/download_manager.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_drop.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:path/path.dart' as p;
AppController _plain() =>
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
/// 탐색기에서 끌어다 놓을 때 Windows 쪽 플러그인이 보내는 것과 같은 메시지
Future<void> _native(WidgetTester tester, String method, Object? args) async {
const codec = StandardMethodCodec();
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('desktop_drop', codec.encodeMethodCall(MethodCall(method, args)), (_) {});
}
void main() {
test('끌어다 놓은 것에서 동영상 고르기: 파일 · 폴더 · jj_mkv 안의 파일 · 이미 있는 것', () async {
final dir = Directory.systemTemp.createTempSync('jj_drop_');
addTearDown(() => dir.deleteSync(recursive: true));
String make(String rel) {
final f = File(p.join(dir.path, rel))..createSync(recursive: true);
f.writeAsStringSync('x');
return f.path;
}
final a = make('a.mp4');
final made = make(p.join('jj_mkv', '만든 것.mkv'));
make(p.join('시리즈', '1화.mkv'));
make(p.join('시리즈', '2화.mkv'));
make(p.join('시리즈', 'jj_mkv', '1화.mkv')); // 폴더째 놓을 때는 출력 폴더를 건너뜀
final note = make('메모.txt');
final c = _plain();
// 파일 하나
expect(await addDroppedVideos(c, [a]), (1, 1));
// 직접 놓은 파일은 jj_mkv 안이어도 추가, 동영상이 아닌 것은 무시, 폴더는 안쪽까지
expect(await addDroppedVideos(c, [made, note, p.join(dir.path, '시리즈')]), (3, 3));
expect([for (final v in c.videos) v.fileName], ['a.mp4', '만든 것.mkv', '1화.mkv', '2화.mkv']);
// 이미 있는 것
expect(await addDroppedVideos(c, [a, made]), (0, 2));
// 동영상이 없음
expect(await addDroppedVideos(c, [note]), (0, 0));
expect(c.videos, hasLength(4));
});
testWidgets('다른 화면 (다운로드) 에서 끌어다 놓아도 MKV 만들기의 동영상 목록에 추가 + 안내', (tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_drop2_');
addTearDown(() => dir.deleteSync(recursive: true));
final file = p.join(dir.path, '끌어온 영상.mp4');
File(file).writeAsStringSync('x');
final c = _plain();
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppDropArea(c: c, child: child!),
home: DownloadsPage(d: d), // MKV 화면이 아닌 화면
));
await tester.pump();
// 끌고 들어오면 안내
await _native(tester, 'entered', [400.0, 300.0]);
await tester.pump();
expect(find.textContaining('여기에 놓으면'), findsOneWidget);
// 놓기
await tester.runAsync(() async {
await _native(tester, 'performOperation', [file]);
for (var i = 0; i < 50 && c.videos.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pump();
expect([for (final v in c.videos) v.fileName], ['끌어온 영상.mp4']);
expect(c.selected, c.videos.single);
expect(find.textContaining('여기에 놓으면'), findsNothing);
expect(find.textContaining('동영상 목록에 1개를 추가했습니다'), findsOneWidget);
// 같은 것을 다시 놓으면 "이미 있음"
await _native(tester, 'entered', [400.0, 300.0]);
await tester.pump();
await tester.runAsync(() async {
await _native(tester, 'performOperation', [file]);
await Future<void>.delayed(const Duration(milliseconds: 200));
});
// 앞의 안내가 사라지고 새 안내가 나올 때까지
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
expect(c.videos, hasLength(1));
expect(find.textContaining('이미 동영상 목록에 있습니다'), findsOneWidget);
d.dispose();
});
}

168
test/scale_menu_test.dart Normal file
View File

@ -0,0 +1,168 @@
import 'dart:io';
import 'package:flutter/gestures.dart' show kSecondaryButton;
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/models.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/app_shell.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';
import 'package:jj_mkvmaker/ui/home_page.dart';
import 'package:path/path.dart' as p;
class _Shell extends NoopShell {
final revealed = <String>[];
@override
Future<void> revealFile(String path) async => revealed.add(path);
}
void main() {
test('화면 크기 설정: 범위 · 5% 단위 · 저장', () {
expect(AppSettings.clampUiScale(null), 1.0);
expect(AppSettings.clampUiScale(0.1), 0.7);
expect(AppSettings.clampUiScale(9), 1.6);
expect(AppSettings.clampUiScale(1.1000000001), 1.1);
final s = AppSettings()
..uiScale = 1.2
..uiScaleDefault = 0.9;
final back = AppSettings.fromJson(s.toJson());
expect([back.uiScale, back.uiScaleDefault], [1.2, 0.9]);
});
testWidgets('화면 크기 버튼: − · + 로 10% 씩, 숫자를 누르면 기본 크기로, 화면이 실제로 커진다', (tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.pumpWidget(MaterialApp(
builder: (context, child) => UiScaler(c: c, child: child!),
home: Scaffold(
body: Column(children: [
Row(children: [const Spacer(), AppActions(c: c, onExit: () {})]),
const SizedBox(width: 100, height: 100, key: Key('box')),
]),
),
));
expect(find.text('100%'), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('box'))), const Size(100, 100));
final settingsAt = tester.getCenter(find.byTooltip('환경 설정'));
// − · 숫자 · + 는 환경 설정 버튼의 왼쪽
expect(tester.getCenter(find.byTooltip('화면 크게')).dx, lessThan(settingsAt.dx));
await tester.tap(find.byTooltip('화면 크게'));
await tester.pump();
await tester.tap(find.byTooltip('화면 크게'));
await tester.pump();
expect(c.settings.uiScale, 1.2);
expect(find.text('120%'), findsOneWidget);
// 화면에 그려지는 크기가 1.2 배
final r = tester.getRect(find.byKey(const Key('box')));
expect(r.width, closeTo(120, 0.01));
// 안쪽은 1200/1.2 = 1000 너비의 화면이라고 여기고 배치 → 오른쪽 끝 버튼은 여전히 창 오른쪽 끝
expect(tester.getRect(find.byTooltip('종료')).right, closeTo(1200, 10));
await tester.tap(find.byTooltip('화면 작게'));
await tester.pump();
expect(c.settings.uiScale, 1.1);
// 기본 크기 90% 로 정해 두고 숫자를 누르면 그 크기로
c.settings.uiScaleDefault = 0.9;
await tester.tap(find.text('110%'));
await tester.pump();
expect(c.settings.uiScale, 0.9);
expect(find.text('90%'), findsOneWidget);
// 끝까지 줄이면 − 가 꺼진다
c.settings.uiScale = AppSettings.uiScaleMin;
c.updateSettings((_) {});
await tester.pump();
expect(tester.widget<IconButton>(find.widgetWithIcon(IconButton, Icons.remove)).onPressed, isNull);
});
testWidgets('동영상 목록 오른쪽 클릭 메뉴 · [폴더 열기] 버튼', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final shell = _Shell();
final c = AppController(
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell));
c.videos.addAll([VideoItem(r'D:\v\첫째.mp4'), VideoItem(r'D:\v\둘째.mp4')]);
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
// [폴더 열기] (재생 버튼 옆): 지금 보고 있는 동영상
await tester.tap(find.text('폴더 열기'));
expect(shell.revealed, [r'D:\v\첫째.mp4']);
// 둘째 줄에서 오른쪽 클릭 → 그 줄이 선택되고 메뉴
await tester.tapAt(tester.getCenter(find.text('둘째.mp4').first), buttons: kSecondaryButton);
await tester.pumpAndSettle();
expect(c.selected, c.videos[1]);
for (final label in ['대상 폴더 열기', '자막 파일 추가', '삭제 (목록에서 제거)']) {
expect(find.text(label), findsWidgets, reason: label);
}
await tester.tap(find.text('대상 폴더 열기'));
await tester.pumpAndSettle();
expect(shell.revealed.last, r'D:\v\둘째.mp4');
// 삭제
await tester.tapAt(tester.getCenter(find.text('둘째.mp4').first), buttons: kSecondaryButton);
await tester.pumpAndSettle();
await tester.tap(find.text('삭제 (목록에서 제거)'));
await tester.pumpAndSettle();
expect([for (final v in c.videos) v.fileName], ['첫째.mp4']);
});
testWidgets('MKV 화면 위쪽 막대: 창이 좁아도 넘치지 않고 설정 · 종료 버튼이 창 안에 있다', (tester) async {
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_bar_');
addTearDown(() => dir.deleteSync(recursive: true));
final c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'),
storage: DesktopStorageService(),
createRecognizer: () => throw UnimplementedError(),
createTranslator: () => throw UnimplementedError(),
createMediaPlayer: () => throw UnimplementedError(),
usage: _Usage(),
));
c.videos.addAll([VideoItem(r'D:\v\a.mp4'), VideoItem(r'D:\v\b.mp4')]);
c.selected = c.videos.first;
c.toggleChecked(c.videos.first);
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);
final bm = BookmarksController(p.join(dir.path, 'bm.json'));
for (final width in [1900.0, 1600.0, 1300.0, 1100.0, 900.0]) {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
await tester.pumpWidget(MaterialApp(
key: UniqueKey(), home: HomePage(c: c, downloads: d, bookmarks: bm, onExit: () {})));
await tester.pump();
expect(tester.takeException(), isNull, reason: '너비 $width 에서 넘침');
final exit = tester.getRect(find.byTooltip('종료'));
expect(exit.right, lessThanOrEqualTo(width), reason: '너비 $width: 종료 버튼이 창 밖');
expect(tester.getRect(find.byTooltip('화면 크게')).left, greaterThan(0));
// 넓을 때는 글이 있는 버튼, 좁을 때는 아이콘만 (기능은 그대로)
expect(find.text('자막 만들기 & MKV 만들기 (1)'), width >= 1900 ? findsOneWidget : anything);
expect(find.textContaining('MKV 만들기 (1)'), findsWidgets);
}
// 가장 좁을 때: 아이콘만 남고 설명은 마우스를 올리면
expect(find.text('동영상 추가'), findsNothing);
expect(find.byTooltip(RegExp('^동영상 추가')), findsOneWidget);
expect(find.byTooltip(RegExp('^선택한 파일 재생')), findsOneWidget);
d.dispose();
});
}
class _Usage implements SystemUsage {
@override
UsageSample? read() => const UsageSample(cpu: 0.5, memUsed: 8 << 30, memTotal: 16 << 30);
@override
(int, int)? disk(String path) => (100 << 30, 500 << 30);
}