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
parent
1ba49ffa8d
commit
996dd44cb5
|
|
@ -27,7 +27,8 @@
|
|||
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
|
||||
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
|
||||
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
|
||||
| **상태 표시** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 환경 설정 · 종료 버튼 |
|
||||
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
|
||||
| **끌어다 놓기 · 오른쪽 클릭** | 어느 화면에서든 동영상 · 폴더를 놓으면 목록에 추가. 목록에서 오른쪽 클릭: 폴더 열기 · 재생 · 자막 만들기 · 자막 찾기 · 삭제 |
|
||||
|
||||
## 실행
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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/'
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,106 +102,133 @@ class _TopBar extends StatelessWidget {
|
|||
final batch = c.batchTargets;
|
||||
final canBatch = batch.isNotEmpty && c.ffmpegVersion != null;
|
||||
final count = c.checked.isEmpty ? '' : ' (${c.checked.length})';
|
||||
return Container(
|
||||
height: appBarHeight,
|
||||
color: JjColors.panel,
|
||||
padding: const EdgeInsets.only(left: 16, right: appBarRightPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
// 프로그램 아이콘 (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)),
|
||||
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) ...[
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: _batchHint(c, 'AI 자막을 만듭니다'),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null,
|
||||
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||
label: Text('자막 만들기$count'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: _batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다'),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: canBatch
|
||||
? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true)
|
||||
: null,
|
||||
icon: const Icon(Icons.auto_mode, size: 18),
|
||||
label: Text('자막 만들기 & MKV 만들기$count'),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (c.busy) ...[
|
||||
JobIndicator(c: c),
|
||||
// 창 너비에 맞춰 차례로 줄인다: 제목 글 → 왼쪽 버튼을 아이콘만 → 재생 · 브라우저를 아이콘만 → 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,
|
||||
padding: const EdgeInsets.only(left: 16, right: appBarRightPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
// 프로그램 아이콘 (tool/make_icon.py 로 생성)
|
||||
Image.asset('assets/icon/app_icon_256.png', width: 28, height: 28, filterQuality: FilterQuality.medium),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: c.cancel,
|
||||
icon: const Icon(Icons.stop, size: 18, color: JjColors.danger),
|
||||
label: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개)
|
||||
if (c.services.createMediaPlayer != null) ...[
|
||||
Tooltip(
|
||||
message: c.checked.isEmpty
|
||||
? '지금 보고 있는 동영상을 재생합니다 (여러 개는 목록에서 체크)'
|
||||
: '체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서 재생합니다',
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: batch.isEmpty
|
||||
? null
|
||||
: () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true),
|
||||
icon: const Icon(Icons.playlist_play, size: 20),
|
||||
label: Text('선택한 파일 재생$count'),
|
||||
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: [
|
||||
action(Icons.add, '동영상 추가', c.pickVideos,
|
||||
text: leftText, tip: '동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'),
|
||||
if (hasAi) ...[
|
||||
const SizedBox(width: 8),
|
||||
action(Icons.auto_awesome, '자막 만들기$count',
|
||||
canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null,
|
||||
text: leftText, tip: '자막 만들기: ${_batchHint(c, 'AI 자막을 만듭니다')}'),
|
||||
const SizedBox(width: 8),
|
||||
action(
|
||||
Icons.auto_mode,
|
||||
'자막 만들기 & MKV 만들기$count',
|
||||
canBatch
|
||||
? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true)
|
||||
: null,
|
||||
text: leftText,
|
||||
tip: '자막 만들기 & MKV 만들기: ${_batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다')}'),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
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 ? ' (대기열)' : ''}'),
|
||||
),
|
||||
if (bookmarks != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks!))),
|
||||
icon: const Icon(Icons.public, size: 18),
|
||||
label: const Text('브라우저'),
|
||||
if (c.busy) ...[
|
||||
JobIndicator(c: c, compact: !jobText, iconOnly: !jobText),
|
||||
const SizedBox(width: 8),
|
||||
action(Icons.stop, '취소', c.cancel, text: jobText, tip: '작업 취소', color: JjColors.danger),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개)
|
||||
if (hasPlayer) ...[
|
||||
action(
|
||||
Icons.playlist_play,
|
||||
'선택한 파일 재생$count',
|
||||
batch.isEmpty
|
||||
? null
|
||||
: () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true),
|
||||
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 && jobText ? ' (대기열)' : ''}'),
|
||||
),
|
||||
if (bookmarks != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
action(
|
||||
Icons.public,
|
||||
'브라우저',
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks!))),
|
||||
text: rightText,
|
||||
tip: '웹 브라우저'),
|
||||
],
|
||||
if (downloads != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_DownloadBox(d: downloads!),
|
||||
],
|
||||
// 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리
|
||||
AppActions(c: c, onExit: onExit, showUsage: usage),
|
||||
],
|
||||
if (downloads != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_DownloadBox(d: downloads!),
|
||||
],
|
||||
// 환경 설정 · 종료: 모든 화면에서 같은 자리
|
||||
AppActions(c: c, onExit: onExit),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('처음 화면'),
|
||||
|
|
|
|||
|
|
@ -26,86 +26,135 @@ class WorkPanel extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: Listenable.merge([c, ?downloads]),
|
||||
builder: (context, _) => Container(
|
||||
color: JjColors.panel,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
_header(context),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView(padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), children: [
|
||||
listenable: Listenable.merge([c, ?downloads]),
|
||||
builder: (context, _) => Container(
|
||||
color: JjColors.panel,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_header(context),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
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}개',
|
||||
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),
|
||||
_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),
|
||||
],
|
||||
]),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _header(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
|
||||
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),
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
if (c.busy)
|
||||
IconButton(
|
||||
tooltip: c.busy ? 'MKV 만들기 (대기열에 추가)' : 'MKV 만들기',
|
||||
tooltip: '모든 작업 취소',
|
||||
iconSize: 18,
|
||||
icon: const Icon(Icons.play_arrow, color: JjColors.accent),
|
||||
onPressed: c.videos.isEmpty || c.ffmpegVersion == null ? null : c.buildAll,
|
||||
icon: const Icon(Icons.stop, color: JjColors.danger),
|
||||
onPressed: c.cancel,
|
||||
),
|
||||
if (c.busy)
|
||||
IconButton(
|
||||
tooltip: '모든 작업 취소',
|
||||
iconSize: 18,
|
||||
icon: const Icon(Icons.stop, color: JjColors.danger),
|
||||
onPressed: c.cancel,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'MKV 화면으로 (전체 화면)',
|
||||
iconSize: 18,
|
||||
icon: const Icon(Icons.open_in_full),
|
||||
onPressed: onOpenHome,
|
||||
),
|
||||
IconButton(tooltip: '작업 현황 닫기', iconSize: 18, icon: const Icon(Icons.close), onPressed: onClose),
|
||||
]),
|
||||
);
|
||||
IconButton(
|
||||
tooltip: 'MKV 화면으로 (전체 화면)',
|
||||
iconSize: 18,
|
||||
icon: const Icon(Icons.open_in_full),
|
||||
onPressed: onOpenHome,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '작업 현황 닫기',
|
||||
iconSize: 18,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _jobs() => [
|
||||
_title('작업'),
|
||||
if (!c.busy)
|
||||
const _Dim('쉬는 중')
|
||||
else ...[
|
||||
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)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
];
|
||||
_title('작업'),
|
||||
if (!c.busy)
|
||||
const _Dim('쉬는 중')
|
||||
else ...[
|
||||
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),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
];
|
||||
|
||||
Widget _title(String t, {Widget? action}) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(t,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.textDim))),
|
||||
?action,
|
||||
]),
|
||||
);
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
t,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: JjColors.textDim,
|
||||
),
|
||||
),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Dim extends StatelessWidget {
|
||||
|
|
@ -113,9 +162,12 @@ class _Dim extends StatelessWidget {
|
|||
const _Dim(this.text);
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(text, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
);
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _VideoRow extends StatelessWidget {
|
||||
|
|
@ -133,31 +185,52 @@ class _VideoRow extends StatelessWidget {
|
|||
final sub = v.status == JobStatus.running
|
||||
? '${v.phase ?? ''} ${(v.progress * 100).round()}%'
|
||||
: v.phase ??
|
||||
switch (v.status) {
|
||||
JobStatus.done => 'MKV 완성',
|
||||
JobStatus.failed => '실패: ${v.message ?? ''}',
|
||||
_ => '자막 ${v.subtitles.where((s) => s.enabled).length}개',
|
||||
};
|
||||
switch (v.status) {
|
||||
JobStatus.done => 'MKV 완성',
|
||||
JobStatus.failed => '실패: ${v.message ?? ''}',
|
||||
_ => '자막 ${v.subtitles.where((s) => s.enabled).length}개',
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
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)),
|
||||
if (v.status == JobStatus.running)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: LinearProgressIndicator(value: v.progress <= 0 ? null : v.progress, minHeight: 3),
|
||||
),
|
||||
Text(sub,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 11, color: v.status == JobStatus.failed ? JjColors.danger : color)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
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),
|
||||
),
|
||||
if (v.status == JobStatus.running)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: LinearProgressIndicator(
|
||||
value: v.progress <= 0 ? null : v.progress,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
sub,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
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: [
|
||||
Expanded(
|
||||
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 (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),
|
||||
onPressed: inList ? null : () => c.addDownloaded(files),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
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 (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,
|
||||
),
|
||||
onPressed: inList ? null : () => c.addDownloaded(files),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -200,37 +291,59 @@ 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: [
|
||||
SizedBox(
|
||||
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),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: compact ? 160 : 240),
|
||||
child: Text(
|
||||
'${c.currentJob ?? '작업 중'}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? ' · 대기 $wait' : ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.accent),
|
||||
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(
|
||||
'${c.currentJob ?? '작업 중'}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? ' · 대기 $wait' : ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue