v1.1.0: 웹 브라우저, 자막 번역, 작업 대기열, 화면 분할

- 앱 안 브라우저 (Edge WebView2): 동영상 페이지 [다운로드], 즐겨찾기
  (표시줄 · 폴더 · 실행 취소 · Chrome/Edge/Whale 가져오기), 로그인 쿠키를
  cookies.txt 로 내보내 yt-dlp 와 공유
- 자막 번역: 있는 자막을 NLLB 로 번역해 jj_mkv\파일명_언어코드.srt 로 저장하고
  MKV 자막에 추가, 인터넷 자막을 받을 때 한국어로 함께 번역
- 작업 대기열: AI 자막 · 자막 번역 · MKV 만들기를 차례대로 실행
- 브라우저 화면 분할 [작업 현황]: 작업 · 편집 목록 · 다운로드 진행
- 다 받은 동영상을 편집 목록에 자동 추가 (설정)
- 설정: 시작 화면, 홈 주소, 외부 브라우저

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
old-history-local
RyuJongJin 2026-09-30 16:18:58 +09:00
parent f5f0098018
commit 960a695c7e
28 changed files with 2723 additions and 44 deletions

View File

@ -18,10 +18,13 @@
| **자막 편집** | SRT · SMI · ASS · VTT · 내장 자막 편집, 문자셋 변환(UTF-8 · CP949 · Shift-JIS · GBK · UTF-16) |
| **싱크 맞추기** | 영상을 보면서 편집, 타임라인에서 끌어서 이동·길이 조절, "선택 줄부터 여기로 맞추기" |
| **AI 자막** | Whisper 음성인식 → `파일명_AI.srt`, NLLB 번역 → `파일명_ko.srt` · `_en` · `_ja` … (200개 언어) |
| **자막 번역** | 이미 있는 자막(예: 영어)을 원하는 언어로 번역해 MKV 에 추가. 인터넷에서 받은 자막도 한국어로 함께 번역 |
| **인터넷 자막 찾기** | OpenSubtitles.com 검색 (영상 해시로 "이 파일용" 자막 우선) |
| **동영상 플레이어** | 재생 목록, 전체 화면, 팝업(항상 위), 원본의 0.5·1·2·4배, 자막·음성 트랙 선택, 속도 |
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기 |
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이) |
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
## 실행
@ -52,11 +55,12 @@ OpenSubtitles 의 무료 API 키가 필요합니다.
|---|---|
| 플레이어 | Space 재생/정지 · ←/→ 10초 · Shift+←/→ 1분 · ↑/↓ 음량 · F 전체 화면 · Esc 나가기 · N/P 다음/이전 · M 음소거 · S 자막 · L 재생 목록 |
| 자막 편집 | Ctrl+Space 재생/정지 · Alt+←/→ 1초 · Alt+Shift+←/→ 0.1초 · F7 새 줄 · F8 선택 줄 재생 · F9 시작=현재 · F10 끝=현재 |
| 브라우저 | Ctrl+L 주소창 · F5 새로고침 · Alt+←/→ 뒤로/앞으로 · Ctrl+D 즐겨찾기 · Ctrl+Shift+B 즐겨찾기 관리 · Ctrl+Shift+J 작업 현황 |
| 어디서나 | Ctrl+Shift+X 창 보이기 (환경 설정에서 변경) |
## 소스에서 빌드
필요: Flutter 3.47+, Visual Studio 2022 Build Tools (C++ 데스크톱), Windows 개발자 모드
필요: Flutter 3.47+, Visual Studio 2022 Build Tools (C++ 데스크톱), Windows 개발자 모드, PATH 에 `nuget.exe` (앱 안 브라우저 WebView2 빌드용)
```powershell
git clone <이 저장소>

View File

@ -23,6 +23,10 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지
- onnxruntime (Dart) MIT https://pub.dev/packages/onnxruntime
- window_manager, tray_manager, hotkey_manager, screen_retriever, nativeapi
MIT https://github.com/leanflutter
- flutter_inappwebview Apache-2.0 https://pub.dev/packages/flutter_inappwebview
- Microsoft Edge WebView2 SDK (WebView2Loader.dll)
BSD 3-Clause (Microsoft) https://www.nuget.org/packages/Microsoft.Web.WebView2
앱 안 브라우저는 Windows 에 설치된 Edge WebView2 런타임을 사용합니다 (Windows 11 기본 포함).
- desktop_drop Apache-2.0 https://pub.dev/packages/desktop_drop
- file_picker MIT https://pub.dev/packages/file_picker
- charset Apache-2.0 https://pub.dev/packages/charset

View File

@ -0,0 +1,111 @@
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/app/app_controller.dart';
import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:path/path.dart' as p;
/// 실제 Edge(WebView2): 페이지 열기 · 동영상 감지 · 쿠키 저장 → yt-dlp 가 같은 쿠키를 읽는지
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('앱 안 브라우저 → 쿠키 → yt-dlp', (tester) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((req) async {
req.response
..headers.contentType = ContentType.html
..headers.add('Set-Cookie', 'jjserver=ok; Max-Age=3600; Path=/')
..write('<html><head><meta charset="utf-8"><title>JJ 테스트</title></head><body>'
'<video width="200" height="100"></video>'
'<script>document.cookie = "jjscript=hello; max-age=3600; path=/";</script></body></html>');
await req.response.close();
});
final url = 'http://127.0.0.1:${server.port}/';
final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.webViewDataDir = dataDir;
final bm = BookmarksController(p.join(dataDir, 'bm.json'));
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);
await tester.pumpWidget(MaterialApp(home: BrowserPage(c: c, bookmarks: bm, downloads: d, initialUrl: url)));
// 페이지가 열리고 <video> 를 찾을 때까지 (최대 30초)
bool videoSeen = false;
for (var i = 0; i < 300 && !videoSeen; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
final btn = tester.widget<FilledButton>(find.ancestor(
of: find.text('다운로드'), matching: find.byWidgetPredicate((w) => w is FilledButton)));
videoSeen = btn.onPressed != null;
}
// ignore: avoid_print
print('RESULT 동영상 감지(다운로드 버튼 켜짐): $videoSeen');
expect(videoSeen, isTrue);
// YouTube 에 로그인한 것처럼 쿠키를 심고 (HttpOnly 포함) [다운로드] → cookies.txt 내보내기
final env = await tester.runAsync(() => browserEnvironment(dataDir));
final cm = CookieManager.instance(webViewEnvironment: env);
final future = DateTime.now().add(const Duration(days: 30)).millisecondsSinceEpoch;
await tester.runAsync(() async {
await cm.setCookie(url: WebUri('https://www.youtube.com/'), name: 'LOGIN_INFO', value: 'jj-login',
domain: '.youtube.com', isSecure: true, isHttpOnly: true, expiresDate: future);
await cm.setCookie(url: WebUri('https://www.youtube.com/'), name: 'PREF', value: 'hl=ko',
domain: '.youtube.com', isSecure: true, expiresDate: future);
});
await tester.tap(find.text('다운로드'));
for (var i = 0; i < 50 && c.settings.internalCookieFile.isEmpty; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
}
final cookieFile = c.settings.internalCookieFile;
final text = cookieFile.isEmpty ? '' : File(cookieFile).readAsStringSync();
// ignore: avoid_print
print('RESULT 내보낸 쿠키 파일: "$cookieFile"\n${text.split('\n').where((l) => l.contains('youtube')).join('\n')}');
expect(text, contains('#HttpOnly_.youtube.com'));
expect(text, contains('LOGIN_INFO\tjj-login'));
// 만료 시각은 초 단위 (지금부터 약 30일 뒤)
final expLine = text.split('\n').firstWhere((l) => l.contains('LOGIN_INFO'));
final exp = int.parse(expLine.split('\t')[4]);
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
expect(exp, inInclusiveRange(now + 29 * 86400, now + 31 * 86400));
// yt-dlp 에 넘길 인수는 내보낸 파일이 우선 (브라우저가 켜져 있어도 됨)
final args = ytDlpCookieArgs(
browser: internalBrowserCookies,
internalProfile: c.settings.internalCookieProfile,
internalCookieFile: cookieFile);
expect(args, ['--cookies', cookieFile]);
// yt-dlp 가 이 파일을 문제없이 읽는지 (브라우저가 켜진 상태에서)
final ytdlp = p.join(Directory.current.path, 'third_party', 'tools', 'windows', 'yt-dlp.exe');
final copy = p.join(dataDir, 'check.txt');
File(cookieFile).copySync(copy);
final r = await tester.runAsync(() => Process.run(ytdlp,
['-v', '--simulate', '--cookies', copy, url], environment: {'PYTHONIOENCODING': 'utf-8'}));
final log = '${r!.stdout}\n${r.stderr}';
// yt-dlp 는 읽은 쿠키를 같은 파일에 다시 저장한다 → 로그인 쿠키가 남아 있으면 제대로 읽은 것
final saved = File(copy).readAsStringSync();
final cookieErrors = log.split('\n').where((l) => l.startsWith('ERROR') && l.toLowerCase().contains('cookie'));
// ignore: avoid_print
print('RESULT yt-dlp 가 다시 저장한 파일에 LOGIN_INFO: ${saved.contains('LOGIN_INFO')}, 쿠키 오류 줄: ${cookieErrors.length}');
expect(saved, contains('This file is generated by yt-dlp'));
expect(saved, contains('LOGIN_INFO\tjj-login'));
expect(cookieErrors, isEmpty);
expect(locateTool('yt-dlp'), isNotEmpty);
await tester.pumpWidget(const SizedBox());
d.dispose();
await server.close(force: true);
}, timeout: const Timeout(Duration(minutes: 3)));
}

View File

@ -0,0 +1,104 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_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/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:path/path.dart' as p;
/// 실제 NLLB + 실제 Edge(WebView2): 브라우저를 보면서 영어 자막 → 한국어 번역 → MKV 를
/// 대기열로 돌리고, 화면 분할 "작업 현황" 에서 진행을 확인 (모델이 JJ_MKVMAKER_MODELS 에 있어야 함)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('브라우저 + 작업 현황: 영어 자막 → 한국어 번역 → MKV (대기열)', (tester) async {
final dir = Directory.systemTemp.createTempSync('jj_split_');
final video = p.join(dir.path, 'movie.mp4');
final r = await Process.run('ffmpeg', [
'-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine',
'-t', '8', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', video,
]);
expect(r.exitCode, 0, reason: '${r.stderr}');
final en = p.join(dir.path, 'movie.en.srt');
File(en).writeAsStringSync('1\n00:00:01,000 --> 00:00:03,000\nGood morning, how are you today?\n\n'
'2\n00:00:04,000 --> 00:00:06,000\nI want to watch this movie with my family.\n\n');
final c = AppController(PlatformServices.create());
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((req) async {
req.response
..headers.contentType = ContentType.html
..write('<html><head><title>JJ</title></head><body>hello</body></html>');
await req.response.close();
});
await tester.runAsync(() async {
await c.init();
await c.addVideos([video]);
});
c.settings.webViewDataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
final v = c.videos.single;
// 같은 폴더의 movie.en.srt 가 자동으로 잡힘
final s = v.subtitles.firstWhere((x) => x.path != null && p.equals(x.path!, en));
// ignore: avoid_print
print('RESULT 자동 추가된 자막: ${s.displayName} 언어=${s.language.code}');
final bm = BookmarksController(p.join(dir.path, 'bm.json'));
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);
await tester.pumpWidget(MaterialApp(
home: BrowserPage(c: c, bookmarks: bm, downloads: d, initialUrl: 'http://127.0.0.1:${server.port}/')));
await tester.pump(const Duration(milliseconds: 500));
// 번역 시작 + 바로 MKV 만들기 (대기열)
final sw = Stopwatch()..start();
final tr = c.translateSubtitle(v, s, {languageOf('ko')});
final mkv = c.buildAll();
await tester.pump();
expect(c.busy, isTrue);
expect(c.pendingJobs, ['MKV 만들기']);
// 화면 분할 열기 → 진행 표시
await tester.tap(find.byTooltip('작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)'));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('작업 현황'), findsOneWidget);
expect(find.textContaining('▶ 자막 번역'), findsOneWidget);
expect(find.textContaining('MKV 만들기 (대기)'), findsOneWidget);
final phases = <String>{};
while (c.busy) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
await tester.pump();
if (v.phase != null) phases.add(v.phase!.replaceAll(RegExp(r'\d+%'), '%'));
}
await tester.runAsync(() => Future.wait([tr, mkv]));
await tester.pump();
// ignore: avoid_print
print('RESULT 걸린 시간 ${sw.elapsedMilliseconds}ms, 단계: ${phases.join(' | ')}');
// ignore: avoid_print
print(c.logs.join('\n'));
expect(find.text('쉬는 중'), findsOneWidget);
expect(find.text('MKV 완성'), findsOneWidget);
final koPath = languageSubtitlePath(video, languageOf('ko'));
final ko = File(koPath).readAsStringSync();
// ignore: avoid_print
print('RESULT --- ko ---\n$ko');
expect(RegExp(r'[가-힣]').hasMatch(ko), isTrue);
expect(ko, contains('00:00:04,000 --> 00:00:06,000'));
expect(v.status, JobStatus.done, reason: v.message);
final info = await tester.runAsync(() => c.services.mediaTool.probe(outputMkvPath(video)));
final langs = info!.ofType('subtitle').map((x) => x.language).toList();
// ignore: avoid_print
print('RESULT MKV 자막 언어: $langs');
expect(langs, containsAll(['kor', 'eng']));
await tester.pumpWidget(const SizedBox());
d.dispose();
await server.close(force: true);
}, timeout: const Timeout(Duration(minutes: 15)));
}

View File

@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import '../core/charset_detector.dart';
@ -96,6 +99,10 @@ class AppController extends ChangeNotifier {
Future<void> init() async {
if (settingsStore != null) {
settings = await settingsStore!.load();
// 앱 안 브라우저의 로그인 · 쿠키 폴더 (yt-dlp 가 같은 쿠키를 읽음)
try {
settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview');
} catch (_) {}
encode = settings.encode;
aiOptions = AiOptions(
source: languageOf(settings.aiSource),
@ -215,6 +222,17 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
/// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수.
Future<int> addDownloaded(List<String> files) async {
final before = videos.length;
await addVideos(files);
final n = videos.length - before;
for (final v in videos.skip(before)) {
_log('받은 동영상을 편집 목록에 추가: ${v.fileName}');
}
return n;
}
void removeVideo(VideoItem v) {
if (v.status == JobStatus.running) return;
videos.remove(v);
@ -372,18 +390,68 @@ class AppController extends ChangeNotifier {
Translator? _translator;
bool _aiCancelled = false;
/// [targets] 동영상들의 AI 자막 만들기 (하나씩 차례로)
// ───────── 작업 대기열 ─────────
/// 실행 중인 작업 이름 (없으면 null)
String? currentJob;
/// 대기 중인 작업 이름 (차례대로)
final List<String> pendingJobs = [];
final List<(Future<void> Function(), Completer<void>)> _pendingRuns = [];
/// 작업 중이면 대기열에 넣고, 아니면 바로 실행한다. 끝나면 다음 작업을 이어서 실행.
/// (AI 자막 · 자막 번역 · MKV 만들기 는 CPU · 메모리를 많이 써서 하나씩)
/// 돌려주는 Future 는 그 작업이 실제로 끝날 때 (취소로 빠지면 바로) 끝난다.
Future<void> _enqueue(String label, Future<void> Function() job) {
if (busy) {
final done = Completer<void>();
pendingJobs.add(label);
_pendingRuns.add((job, done));
_log('대기열에 추가: $label (대기 ${pendingJobs.length}개)');
notifyListeners();
return done.future;
}
return _run(label, job);
}
Future<void> _run(String label, Future<void> Function() job) async {
busy = true;
currentJob = label;
notifyListeners();
try {
await job();
} finally {
busy = false;
currentJob = null;
notifyListeners();
if (_pendingRuns.isNotEmpty) {
final (next, done) = _pendingRuns.removeAt(0);
final nextLabel = pendingJobs.removeAt(0);
unawaited(_run(nextLabel, next).then((_) => done.complete(), onError: done.completeError));
}
}
}
/// [targets] 동영상들의 AI 자막 만들기 (하나씩 차례로). 다른 작업 중이면 대기열로.
Future<void> generateAiSubtitles(List<VideoItem> targets, AiOptions opts) async {
if (busy || !aiAvailable || ffmpegVersion == null) return;
if (!aiAvailable || ffmpegVersion == null || targets.isEmpty) return;
aiOptions = opts;
settings
..aiSource = opts.source.code
..aiTargets = [for (final t in opts.targets) t.code]
..aiWhisper = opts.whisper.id;
_saveSettings();
busy = true;
if (busy) {
for (final v in targets) {
v.phase = 'AI 자막 대기 중';
}
}
final label = targets.length == 1 ? 'AI 자막: ${targets.first.fileName}' : 'AI 자막 ${targets.length}개';
return _enqueue(label, () => _runAi(targets, opts));
}
Future<void> _runAi(List<VideoItem> targets, AiOptions opts) async {
_aiCancelled = false;
notifyListeners();
try {
// 1. 모델 준비 (없으면 내려받기)
for (final m in [opts.whisper, nllbModel]) {
@ -417,7 +485,6 @@ class AppController extends ChangeNotifier {
} finally {
await _translator?.dispose();
_translator = null;
busy = false;
notifyListeners();
}
}
@ -573,7 +640,8 @@ class AppController extends ChangeNotifier {
/// 고른 자막 받기 → jj_mkv\파일명_언어코드.srt (UTF-8) 저장 → MKV 자막에 추가
/// 돌려주는 값: 받은 개수
Future<int> downloadSubtitles(VideoItem v, List<SubtitleSearchResult> picks) async {
/// [translateTo] 를 주면 받은 자막을 그 언어로 번역해 함께 추가 (대기열, 기다리지 않음)
Future<int> downloadSubtitles(VideoItem v, List<SubtitleSearchResult> picks, {Language? translateTo}) async {
final provider = subtitleProvider;
if (provider == null) return 0;
final storage = services.storage;
@ -585,6 +653,7 @@ class AppController extends ChangeNotifier {
if (s.path != null) p.basename(s.path!).toLowerCase(),
};
var ok = 0;
var queuedTranslate = false;
for (final r in picks) {
try {
final bytes = await provider.download(r);
@ -595,10 +664,18 @@ class AppController extends ChangeNotifier {
used.add(name.toLowerCase());
final path = p.join(dir, name);
await storage.writeBytes(path, encodeText(formatSrt(cues), 'UTF-8').bytes);
v.subtitles.add(SubtitleEntry.external(
final entry = SubtitleEntry.external(
path: path, language: r.language, charset: 'UTF-8',
title: '${r.language.name} (${provider.name})'));
title: '${r.language.name} (${provider.name})');
v.subtitles.add(entry);
ok++;
if (translateTo != null &&
!queuedTranslate &&
r.language.code != translateTo.code &&
!v.subtitles.any((x) => x.enabled && x.language.code == translateTo.code)) {
queuedTranslate = true; // 한 번만 (여러 언어를 받아도 첫 자막에서 번역)
unawaited(translateSubtitle(v, entry, {translateTo}));
}
_log('자막 받음: $name ← ${r.release}');
} catch (e) {
_log('자막 받기 실패: ${r.release}\n$e');
@ -611,6 +688,102 @@ class AppController extends ChangeNotifier {
return ok;
}
// ───────── 자막 번역 (있는 자막 → 다른 언어) ─────────
/// 자막 [s] 를 [targets] 언어로 번역해 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 자막에 추가.
/// 원어는 자막의 언어 태그, 없으면 글자로 추정. 이 PC 안에서 NLLB 로 번역 (다른 작업 중이면 대기열로).
Future<void> translateSubtitle(VideoItem v, SubtitleEntry s, Set<Language> targets) async {
if (services.createTranslator == null || targets.isEmpty) return;
if (busy) v.phase = '자막 번역 대기 중';
notifyListeners();
return _enqueue('자막 번역: ${v.fileName} → ${targets.map((t) => t.code).join('/')}',
() => _runTranslate(v, s, targets));
}
Future<void> _runTranslate(VideoItem v, SubtitleEntry s, Set<Language> targets) async {
_aiCancelled = false;
void phase(String t, double p) {
v
..status = JobStatus.running
..phase = t
..progress = p;
notifyListeners();
}
Translator? tr;
try {
phase('자막 불러오는 중', 0);
final cues = await loadCues(v, s);
if (cues.isEmpty) throw const MediaToolException('자막이 비어 있습니다.');
final src = s.language != undetermined
? s.language
: detectLanguage(cues.take(200).map((c) => c.text).join(' '));
if (src == undetermined) throw const MediaToolException('자막 언어를 알 수 없습니다. 자막 줄에서 언어를 먼저 고르세요.');
final langs = targets.where((t) => t.code != src.code).toList();
if (langs.isEmpty) throw MediaToolException('원어(${src.name})와 같은 언어로는 번역하지 않습니다.');
if (!await services.models.isInstalled(nllbModel)) {
_log('번역 모델 내려받는 중: ${nllbModel.sizeLabel}');
await services.models.download(nllbModel,
isCancelled: () => _aiCancelled, onProgress: (x) => phase('번역 모델 내려받는 중 ${(x * 100).round()}%', x * 0.2));
}
phase('번역 모델 불러오는 중', 0.2);
tr = _translator = services.createTranslator!();
await tr.load(await services.models.folderOf(nllbModel));
final dir = outputDirFor(v.path);
await services.storage.ensureDirectory(dir);
final used = {
for (final f in await services.storage.listFiles(dir)) p.basename(f).toLowerCase(),
for (final e in v.subtitles)
if (e.path != null) p.basename(e.path!).toLowerCase(),
};
for (var i = 0; i < langs.length; i++) {
if (_aiCancelled) throw const AiCancelled();
final tgt = langs[i];
final base = 0.25 + 0.75 * i / langs.length;
final span = 0.75 / langs.length;
final texts = await tr.translate(
[for (final c in cues) c.text.replaceAll('\n', ' ')],
source: src.nllb,
target: tgt.nllb,
onProgress: (x) => phase('${src.name} → ${tgt.name} 번역 ${(x * 100).round()}%', base + span * x),
);
final out = [
for (var k = 0; k < cues.length; k++)
Cue(cues[k].start, cues[k].end,
cleanRecognized([Cue(cues[k].start, cues[k].end, texts[k])]).firstOrNull?.text ?? texts[k]),
];
final name = nextFreeName('${v.baseName}_${tgt.code}.srt', used);
used.add(name.toLowerCase());
final path = p.join(dir, name);
await services.storage.writeBytes(path, encodeText(formatSrt(out), 'UTF-8').bytes);
v.subtitles.add(SubtitleEntry.external(
path: path, language: tgt, charset: 'UTF-8', title: '${tgt.name} (AI 번역 ← ${src.code})'));
_log('자막 번역 저장: $name (${src.name} → ${tgt.name}, ${cues.length}줄)');
}
v
..status = JobStatus.ready
..phase = null
..progress = 0;
} on AiCancelled {
v
..status = JobStatus.ready
..phase = null;
_log('자막 번역 취소: ${v.fileName}');
} catch (e) {
v
..status = JobStatus.failed
..phase = null
..message = e is MediaToolException ? e.message : '$e';
_log('자막 번역 실패: ${v.fileName}\n${v.message}');
} finally {
await tr?.dispose();
_translator = null;
notifyListeners();
}
}
// ───────── 동영상 재생 ─────────
/// 재생 준비. 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null,
@ -650,11 +823,14 @@ class AppController extends ChangeNotifier {
// ───────── MKV 만들기 ─────────
/// 목록의 MKV 만들기 (다른 작업 중이면 대기열로)
Future<void> buildAll() async {
if (busy || ffmpegVersion == null) return;
busy = true;
if (ffmpegVersion == null) return;
return _enqueue('MKV 만들기', _runBuildAll);
}
Future<void> _runBuildAll() async {
_buildCancelled = false;
notifyListeners();
try {
// 동시에 [maxParallelJobs] 개씩 (0 = 무제한). 작업자가 목록에서 하나씩 가져가 처리한다.
final queue = videos.where((v) => v.status != JobStatus.done).toList();
@ -671,7 +847,6 @@ class AppController extends ChangeNotifier {
final ok = videos.where((v) => v.status == JobStatus.done).length;
_log('완료: 성공 $ok / 전체 ${videos.length}');
} finally {
busy = false;
notifyListeners();
}
}
@ -715,7 +890,17 @@ class AppController extends ChangeNotifier {
bool _buildCancelled = false;
/// 지금 작업 중단 + 대기 중인 작업 모두 비우기
void cancel() {
if (pendingJobs.isNotEmpty) _log('대기 중인 작업 ${pendingJobs.length}개 취소');
pendingJobs.clear();
for (final (_, done) in _pendingRuns) {
done.complete();
}
_pendingRuns.clear();
for (final v in videos) {
if (v.status != JobStatus.running) v.phase = null;
}
_buildCancelled = true;
_aiCancelled = true;
_translator?.cancel();

View File

@ -0,0 +1,124 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../core/bookmarks.dart';
/// 즐겨찾기 (bookmarks.json 에 저장). 바뀔 때마다 저장한다.
class BookmarksController extends ChangeNotifier {
final String? _path;
BookmarkTree tree = BookmarkTree.defaults();
/// 마지막으로 지운 것 (실행 취소용)
(BookmarkNode, String, int)? _lastRemoved;
BookmarksController([this._path]);
Future<String> _file() async =>
_path ?? p.join((await getApplicationSupportDirectory()).path, 'bookmarks.json');
Future<void> load() async {
try {
final f = File(await _file());
if (await f.exists()) {
tree = BookmarkTree.fromJson(jsonDecode(await f.readAsString()) as Map<String, dynamic>);
}
} catch (_) {
tree = BookmarkTree.defaults();
}
notifyListeners();
}
Future<void> _save() async {
final f = File(await _file());
await f.parent.create(recursive: true);
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(tree.toJson()));
}
void _changed() {
notifyListeners();
_save();
}
BookmarkNode addLink(String title, String url, {String parentId = BookmarkTree.barId, int? index}) {
final n = tree.add(parentId, BookmarkNode.link(tree.newId(), title.trim().isEmpty ? url : title.trim(), url),
index: index);
_changed();
return n;
}
BookmarkNode addFolder(String title, {String parentId = BookmarkTree.barId}) {
final n = tree.add(parentId, BookmarkNode.folder(tree.newId(), title.trim().isEmpty ? '새 폴더' : title.trim()));
_changed();
return n;
}
void update(String id, {String? title, String? url}) {
final n = tree.find(id);
if (n == null) return;
if (title != null && title.trim().isNotEmpty) n.title = title.trim();
if (url != null && !n.isFolder && url.trim().isNotEmpty) n.url = url.trim();
_changed();
}
bool remove(String id) {
final r = tree.remove(id);
if (r == null) return false;
_lastRemoved = r;
_changed();
return true;
}
/// 방금 지운 것 되살리기
bool undoRemove() {
final r = _lastRemoved;
if (r == null) return false;
final (node, parentId, index) = r;
tree.add(parentId, node, index: index);
_lastRemoved = null;
_changed();
return true;
}
bool move(String id, String newParentId, int index) {
final ok = tree.move(id, newParentId, index);
if (ok) _changed();
return ok;
}
/// 같은 폴더 안에서 순서 바꾸기. [newIndex] 는 옮긴 뒤의 최종 위치 (onReorderItem 규칙)
void reorder(String parentId, int oldIndex, int newIndex) {
final parent = tree.find(parentId);
if (parent == null || !parent.isFolder) return;
final list = parent.children!;
if (oldIndex < 0 || oldIndex >= list.length) return;
final item = list.removeAt(oldIndex);
list.insert(newIndex.clamp(0, list.length), item);
_changed();
}
/// Chrome · Edge 즐겨찾기 가져오기. 가져온 주소 수 (파일이 없으면 -1)
Future<int> importFrom(String browser) async {
final local = Platform.environment['LOCALAPPDATA'] ?? '';
final file = switch (browser) {
'chrome' => p.join(local, 'Google', 'Chrome', 'User Data', 'Default', 'Bookmarks'),
'edge' => p.join(local, 'Microsoft', 'Edge', 'User Data', 'Default', 'Bookmarks'),
'whale' => p.join(local, 'Naver', 'Naver Whale', 'User Data', 'Default', 'Bookmarks'),
_ => browser, // 파일 경로 직접
};
final f = File(file);
if (!await f.exists()) return -1;
final name = switch (browser) {
'chrome' => 'Chrome 에서 가져옴',
'edge' => 'Edge 에서 가져옴',
'whale' => 'Whale 에서 가져옴',
_ => '가져온 즐겨찾기',
};
final n = tree.importChromium(jsonDecode(await f.readAsString()) as Map<String, dynamic>, name);
if (n > 0) _changed();
return n;
}
}

View File

@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../core/download_detect.dart';
import '../core/playlist.dart';
import '../services/downloader.dart';
import 'settings.dart';
@ -36,6 +38,21 @@ class DownloadManager extends ChangeNotifier {
final _added = StreamController<DownloadTask>.broadcast();
Stream<DownloadTask> get onAdded => _added.stream;
/// 다 받은 다운로드 알림 (편집 목록 자동 추가용). 한 건에 한 번만.
final _finished = StreamController<DownloadTask>.broadcast();
Stream<DownloadTask> get onFinished => _finished.stream;
final Set<String> _reported = {};
/// 받은 파일 중 동영상 (임시 · 조각 파일 제외, 실제로 있는 것만)
static List<String> videoFilesOf(DownloadTask t, {bool Function(String)? exists}) {
final ok = exists ?? (String f) => File(f).existsSync();
final frag = RegExp(r'\.f\d+\.[^.]+$|\.part$|\.temp\.|\.ytdl$', caseSensitive: false);
return [
for (final f in t.files)
if (isVideoFile(f) && !frag.hasMatch(f) && ok(f)) f,
];
}
int get activeCount => tasks.where((t) => t.unfinished).length;
int get downloadingCount => tasks.where((t) => t.state == DownloadState.downloading).length;
@ -115,6 +132,20 @@ class DownloadManager extends ChangeNotifier {
return t;
}
/// 브라우저에서 보고 있는 페이지 받기 (상단 [다운로드] 버튼).
/// YouTube · 마그넷 · torrent 는 [add] 와 같고, 그 밖의 http 주소는 yt-dlp 로 받는다.
DownloadTask? addPage(String url) {
if (detectLinks(url).isNotEmpty) return add(url);
if (!RegExp(r'^https?://', caseSensitive: false).hasMatch(url)) return null;
if (tasks.any((t) => t.source == url && t.state != DownloadState.cancelled)) return null;
if (backends[DownloadKind.video] == null) return null;
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: url, dir: settings().ytDlpDir);
tasks.insert(0, t);
_added.add(t);
_changed();
return t;
}
Future<void> _expand(DownloadTask holder, DownloadBackend backend) async {
try {
final r = await backend.expandPlaylist(holder.source);
@ -156,6 +187,9 @@ class DownloadManager extends ChangeNotifier {
/// 상태가 바뀔 때: 화면 갱신 + 대기 중인 다음 작업 시작
void _changed() {
for (final t in tasks) {
if (t.state == DownloadState.done && _reported.add(t.id)) _finished.add(t);
}
notifyListeners();
_pump();
}
@ -263,6 +297,7 @@ class DownloadManager extends ChangeNotifier {
void dispose() {
stopClipboardWatch();
_added.close();
_finished.close();
super.dispose();
}
}

View File

@ -28,6 +28,9 @@ class AppSettings {
/// AI 자막: 매번 설정 창을 띄울지 (끄면 마지막 설정으로 바로 시작)
bool askAiOptions = true;
/// 다운로드가 끝난 동영상을 편집 목록(MKV 만들기)에 자동으로 추가
bool addFinishedDownloads = true;
/// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남)
String openSubtitlesKey = '';
String openSubtitlesUser = '';
@ -41,7 +44,7 @@ class AppSettings {
bool ytExpandPlaylists = true;
/// YouTube 로봇 확인 대응: 쿠키를 가져올 브라우저 (빈 값 = 사용 안 함) 또는 cookies.txt 경로
String ytCookiesBrowser = '';
String ytCookiesBrowser = internalBrowserCookies; // 기본: 앱 안 브라우저 로그인 사용
String ytCookiesFile = '';
/// 동시에 받는 다운로드 수 (0 = 무제한)
@ -53,6 +56,19 @@ class AppSettings {
/// 메인 화면 아래 작업 기록 창 보이기
bool showLog = true;
/// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl])
String startScreen = 'home';
String homeUrl = 'https://www.youtube.com/';
/// 앱 안 브라우저 엔진: 'edge' (WebView2, 기본). 'chrome' 은 내려받아 연결 (준비 중)
String browserEngine = 'edge';
/// "외부 브라우저로 열기" 에 쓸 브라우저: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale'
String externalBrowser = 'system';
/// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음
String webViewDataDir = '';
/// 시작할 때 새 버전 확인 (하루 한 번)
bool autoCheckUpdates = true;
String lastUpdateCheck = '';
@ -83,6 +99,7 @@ class AppSettings {
'minimizeToTray': minimizeToTray,
'showHotkey': showHotkey,
'askAiOptions': askAiOptions,
'addFinishedDownloads': addFinishedDownloads,
'openSubtitlesKey': openSubtitlesKey,
'openSubtitlesUser': openSubtitlesUser,
'openSubtitlesPassword': openSubtitlesPassword,
@ -94,6 +111,10 @@ class AppSettings {
'maxParallelDownloads': maxParallelDownloads,
'maxParallelJobs': maxParallelJobs,
'showLog': showLog,
'startScreen': startScreen,
'homeUrl': homeUrl,
'browserEngine': browserEngine,
'externalBrowser': externalBrowser,
'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck,
'skippedVersion': skippedVersion,
@ -120,17 +141,22 @@ class AppSettings {
..minimizeToTray = j['minimizeToTray'] as bool? ?? true
..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X'
..askAiOptions = j['askAiOptions'] as bool? ?? true
..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''
..ytContainer = pick(YtContainer.values, j['ytContainer'], YtContainer.mp4)
..ytQuality = pick(YtQuality.values, j['ytQuality'], YtQuality.best)
..ytExpandPlaylists = j['ytExpandPlaylists'] as bool? ?? true
..ytCookiesBrowser = j['ytCookiesBrowser'] as String? ?? ''
..ytCookiesBrowser = j['ytCookiesBrowser'] as String? ?? internalBrowserCookies
..ytCookiesFile = j['ytCookiesFile'] as String? ?? ''
..maxParallelDownloads = (j['maxParallelDownloads'] as num?)?.toInt() ?? 3
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
..showLog = j['showLog'] as bool? ?? true
..startScreen = j['startScreen'] as String? ?? 'home'
..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/'
..browserEngine = j['browserEngine'] as String? ?? 'edge'
..externalBrowser = j['externalBrowser'] as String? ?? 'system'
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
..skippedVersion = j['skippedVersion'] as String? ?? ''
@ -162,6 +188,22 @@ class AppSettings {
}
String get ytDlpDir => p.join(resolvedDownloadRoot(), 'jj_yt-dlp');
/// 앱 안 브라우저가 내보낸 YouTube · Google 쿠키 (cookies.txt). 없으면 빈 값
String get internalCookieFile {
if (webViewDataDir.isEmpty) return '';
final f = p.join(webViewDataDir, 'cookies_youtube.txt');
return File(f).existsSync() ? f : '';
}
/// 앱 안 브라우저의 쿠키가 있는 프로필 폴더 (로그인한 적이 없으면 빈 값)
String get internalCookieProfile {
if (webViewDataDir.isEmpty) return '';
final profile = p.join(webViewDataDir, 'EBWebView', 'Default');
final hasCookies = File(p.join(profile, 'Network', 'Cookies')).existsSync() ||
File(p.join(profile, 'Cookies')).existsSync();
return hasCookies ? profile : '';
}
String get aria2Dir => p.join(resolvedDownloadRoot(), 'jj_aria2');
}

210
lib/core/bookmarks.dart Normal file
View File

@ -0,0 +1,210 @@
import 'dart:math' as math;
/// 즐겨찾기 항목 (주소) 또는 폴더 (children 이 있음)
class BookmarkNode {
final String id;
String title;
String? url;
final List<BookmarkNode>? children;
BookmarkNode.link(this.id, this.title, String this.url) : children = null;
BookmarkNode.folder(this.id, this.title, [List<BookmarkNode>? items])
: url = null,
children = items ?? [];
bool get isFolder => children != null;
Map<String, Object?> toJson() => {
'id': id,
'title': title,
if (url != null) 'url': url,
if (children != null) 'children': [for (final c in children!) c.toJson()],
};
static BookmarkNode fromJson(Map<String, dynamic> j) {
final kids = j['children'] as List?;
return kids != null
? BookmarkNode.folder(j['id'] as String, j['title'] as String? ?? '',
[for (final k in kids) fromJson(k as Map<String, dynamic>)])
: BookmarkNode.link(j['id'] as String, j['title'] as String? ?? '', j['url'] as String? ?? '');
}
}
/// 즐겨찾기 전체. [bar] = 즐겨찾기 표시줄 (브라우저 위쪽 줄), [other] = 기타 즐겨찾기
class BookmarkTree {
final BookmarkNode bar;
final BookmarkNode other;
int _seq;
BookmarkTree(this.bar, this.other, [this._seq = 1000]);
static const barId = 'bar', otherId = 'other';
/// 처음 쓰는 경우 기본 즐겨찾기
factory BookmarkTree.defaults() => BookmarkTree(
BookmarkNode.folder(barId, '즐겨찾기 표시줄', [
BookmarkNode.link('b1', 'YouTube', 'https://www.youtube.com/'),
BookmarkNode.link('b2', 'YouTube 구독', 'https://www.youtube.com/feed/subscriptions'),
BookmarkNode.link('b3', 'OpenSubtitles', 'https://www.opensubtitles.com/'),
]),
BookmarkNode.folder(otherId, '기타 즐겨찾기'),
);
String newId() => 'n${++_seq}${math.Random().nextInt(1 << 20)}';
List<BookmarkNode> get roots => [bar, other];
Map<String, Object?> toJson() => {'seq': _seq, 'bar': bar.toJson(), 'other': other.toJson()};
factory BookmarkTree.fromJson(Map<String, dynamic> j) => BookmarkTree(
BookmarkNode.fromJson(j['bar'] as Map<String, dynamic>),
BookmarkNode.fromJson(j['other'] as Map<String, dynamic>),
(j['seq'] as num?)?.toInt() ?? 1000,
);
BookmarkNode? find(String id) {
BookmarkNode? walk(BookmarkNode n) {
if (n.id == id) return n;
for (final c in n.children ?? const <BookmarkNode>[]) {
final r = walk(c);
if (r != null) return r;
}
return null;
}
for (final r in roots) {
final f = walk(r);
if (f != null) return f;
}
return null;
}
BookmarkNode? parentOf(String id) {
BookmarkNode? walk(BookmarkNode n) {
for (final c in n.children ?? const <BookmarkNode>[]) {
if (c.id == id) return n;
final r = walk(c);
if (r != null) return r;
}
return null;
}
for (final r in roots) {
final f = walk(r);
if (f != null) return f;
}
return null;
}
/// [url] 과 같은 주소의 즐겨찾기 (끝의 / 무시)
BookmarkNode? findByUrl(String url) {
String norm(String u) => u.trim().replaceFirst(RegExp(r'/+$'), '');
final target = norm(url);
BookmarkNode? walk(BookmarkNode n) {
if (n.url != null && norm(n.url!) == target) return n;
for (final c in n.children ?? const <BookmarkNode>[]) {
final r = walk(c);
if (r != null) return r;
}
return null;
}
for (final r in roots) {
final f = walk(r);
if (f != null) return f;
}
return null;
}
/// 폴더 [parentId] 의 [index] 위치에 추가 (없으면 맨 뒤)
BookmarkNode add(String parentId, BookmarkNode node, {int? index}) {
final parent = find(parentId);
if (parent == null || !parent.isFolder) throw ArgumentError('폴더가 아닙니다: $parentId');
final list = parent.children!;
list.insert((index ?? list.length).clamp(0, list.length), node);
return node;
}
/// 삭제. 되살리기용으로 (노드, 부모 id, 위치) 반환. 기본 폴더는 지울 수 없음.
(BookmarkNode, String, int)? remove(String id) {
if (id == barId || id == otherId) return null;
final parent = parentOf(id);
if (parent == null) return null;
final i = parent.children!.indexWhere((c) => c.id == id);
return (parent.children!.removeAt(i), parent.id, i);
}
/// [id] 가 [ancestorId] 의 안쪽(자손)인지
bool isInside(String id, String ancestorId) {
final a = find(ancestorId);
if (a == null) return false;
bool walk(BookmarkNode n) => n.children?.any((c) => c.id == id || walk(c)) ?? false;
return walk(a);
}
/// 이동 (같은 폴더 안 순서 바꾸기 포함). 폴더를 자기 안으로 옮길 수는 없음.
bool move(String id, String newParentId, int index) {
if (id == barId || id == otherId) return false;
if (id == newParentId || isInside(newParentId, id)) return false;
final target = find(newParentId);
if (target == null || !target.isFolder) return false;
final from = parentOf(id);
if (from == null) return false;
final oldIndex = from.children!.indexWhere((c) => c.id == id);
final node = from.children!.removeAt(oldIndex);
var i = index;
if (identical(from, target) && oldIndex < index) i--; // 같은 폴더에서 뒤로 옮길 때
target.children!.insert(i.clamp(0, target.children!.length), node);
return true;
}
/// 모든 폴더 (이동 대상 고르기용): (폴더, 깊이)
List<(BookmarkNode, int)> folders() {
final out = <(BookmarkNode, int)>[];
void walk(BookmarkNode n, int depth) {
if (!n.isFolder) return;
out.add((n, depth));
for (final c in n.children!) {
walk(c, depth + 1);
}
}
for (final r in roots) {
walk(r, 0);
}
return out;
}
/// Chrome · Edge 의 "Bookmarks" 파일 (JSON) 을 [folderTitle] 폴더로 가져오기. 가져온 주소 수 반환.
int importChromium(Map<String, dynamic> json, String folderTitle) {
final rootsJ = json['roots'] as Map<String, dynamic>? ?? const {};
final folder = BookmarkNode.folder(newId(), folderTitle);
var count = 0;
BookmarkNode? conv(Map<String, dynamic> n) {
if (n['type'] == 'url') {
final u = n['url'] as String? ?? '';
if (!u.startsWith('http')) return null;
count++;
return BookmarkNode.link(newId(), n['name'] as String? ?? u, u);
}
if (n['type'] == 'folder') {
final f = BookmarkNode.folder(newId(), n['name'] as String? ?? '폴더');
for (final c in (n['children'] as List? ?? const [])) {
final x = conv(c as Map<String, dynamic>);
if (x != null) f.children!.add(x);
}
return f;
}
return null;
}
for (final key in ['bookmark_bar', 'other', 'synced']) {
final r = rootsJ[key];
if (r is! Map<String, dynamic>) continue;
final f = conv(r);
if (f != null && f.children!.isNotEmpty) folder.children!.add(f);
}
if (count > 0) other.children!.add(folder);
return count;
}
}

View File

@ -120,13 +120,67 @@ List<String> ytDlpFormatArgs(YtContainer c, YtQuality q) {
};
}
/// yt-dlp 쿠키 인수 (YouTube 로봇 확인 대응). cookies.txt 가 있으면 우선.
List<String> ytDlpCookieArgs({String browser = '', String file = ''}) {
/// 앱 안 브라우저(내장 Edge) 의 쿠키를 쓰는 값
const internalBrowserCookies = '*internal';
/// yt-dlp 쿠키 인수 (YouTube 로그인 · 로봇 확인 대응). cookies.txt 가 있으면 우선.
/// 앱 안 브라우저: [internalCookieFile] (브라우저가 내보낸 cookies.txt) 우선,
/// 없으면 [internalProfile] (…\EBWebView\Default, 브라우저가 꺼져 있을 때만 읽힘).
List<String> ytDlpCookieArgs({
String browser = '',
String file = '',
String internalProfile = '',
String internalCookieFile = '',
}) {
if (file.trim().isNotEmpty) return ['--cookies', file.trim()];
if (browser == internalBrowserCookies) {
if (internalCookieFile.isNotEmpty) return ['--cookies', internalCookieFile];
return internalProfile.isEmpty ? const [] : ['--cookies-from-browser', 'edge:$internalProfile'];
}
if (browser.trim().isNotEmpty) return ['--cookies-from-browser', browser.trim()];
return const [];
}
/// 쿠키 한 개 (브라우저 → yt-dlp 로 넘기기용)
class CookieRecord {
final String name, value, domain, path;
/// 만료 시각 (초, 0 = 세션 쿠키)
final int expires;
final bool secure, httpOnly;
const CookieRecord({
required this.name,
required this.value,
required this.domain,
this.path = '/',
this.expires = 0,
this.secure = false,
this.httpOnly = false,
});
}
/// yt-dlp 가 읽는 Netscape 형식 cookies.txt
String toNetscapeCookies(Iterable<CookieRecord> cookies) {
final sb = StringBuffer('# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n');
final seen = <String>{};
for (final c in cookies) {
if (c.name.isEmpty || c.domain.isEmpty) continue;
if (!seen.add('${c.domain}|${c.path}|${c.name}')) continue;
final sub = c.domain.startsWith('.');
final value = c.value.replaceAll(RegExp(r'[\t\r\n]'), '');
sb.writeln([
'${c.httpOnly ? '#HttpOnly_' : ''}${c.domain}',
sub ? 'TRUE' : 'FALSE',
c.path.isEmpty ? '/' : c.path,
c.secure ? 'TRUE' : 'FALSE',
'${c.expires}',
c.name,
value,
].join('\t'));
}
return sb.toString();
}
/// yt-dlp 오류를 알기 쉬운 안내로
String friendlyYtDlpError(String err) {
if (err.contains('Sign in to confirm') || err.contains('not a bot')) {

26
lib/core/web_address.dart Normal file
View File

@ -0,0 +1,26 @@
import 'download_detect.dart';
/// 주소창 입력 → 이동할 주소. 주소가 아니면 Google 검색.
String normalizeAddress(String input) {
final t = input.trim();
if (t.isEmpty) return 'about:blank';
if (RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*://').hasMatch(t) || t.startsWith('about:')) return t;
// 공백이 없고 점이 있으면 주소 (예: youtube.com, 192.168.0.1:8080/x)
if (!t.contains(' ') && RegExp(r'^[^\s/]+\.[^\s/]+(:\d+)?(/.*)?$').hasMatch(t) ||
RegExp(r'^localhost(:\d+)?(/.*)?$').hasMatch(t)) {
return 'https://$t';
}
return 'https://www.google.com/search?q=${Uri.encodeQueryComponent(t)}';
}
/// yt-dlp 로 받을 수 있는 동영상 페이지로 보이는지 (주소만으로 판단).
/// 페이지 안에 &lt;video&gt; 가 있는지는 브라우저가 따로 확인한다.
bool looksLikeVideoPage(String url) {
if (detectLinks(url).isNotEmpty) return true; // YouTube · 마그넷 · torrent
return RegExp(
r'^https?://([\w-]+\.)*(vimeo\.com/\d|dailymotion\.com/video|twitch\.tv/|tiktok\.com/@[^/]+/video|'
r'instagram\.com/(reel|p)/|x\.com/[^/]+/status|twitter\.com/[^/]+/status|facebook\.com/.+/videos|'
r'tv\.naver\.com/v|chzzk\.naver\.com/(video|clips)|afreecatv\.com|sooplive\.co\.kr|bilibili\.com/video|nicovideo\.jp/watch)',
caseSensitive: false,
).hasMatch(url);
}

View File

@ -5,12 +5,14 @@ import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'app/app_controller.dart';
import 'app/bookmarks_controller.dart';
import 'app/download_manager.dart';
import 'app/settings.dart';
import 'core/playlist.dart';
import 'platform/windows/single_instance.dart';
import 'services/platform_services.dart';
import 'ui/ai_dialog.dart';
import 'ui/browser_page.dart';
import 'ui/downloads_page.dart';
import 'ui/home_page.dart';
import 'ui/player_page.dart';
@ -31,6 +33,9 @@ Future<void> main(List<String> args) async {
final controller = AppController(services, settingsStore: SettingsStore());
await controller.init();
final bookmarks = BookmarksController();
await bookmarks.load();
final downloads = DownloadManager(
backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [],
settings: () => controller.settings,
@ -61,6 +66,14 @@ Future<void> main(List<String> args) async {
final n = downloads.downloadingCount;
services.shell.setTooltip(n > 0 ? 'JJ_MKVMaker - 다운로드 $n개' : 'JJ_MKVMaker');
});
// 다 받은 동영상 → 편집 목록 (설정에서 끌 수 있음)
downloads.onFinished.listen((t) async {
if (!controller.settings.addFinishedDownloads) return;
final files = DownloadManager.videoFilesOf(t);
if (files.isNotEmpty && await controller.addDownloaded(files) > 0) {
messengerKey.currentState?.showSnackBar(SnackBar(content: Text('편집 목록에 추가: ${t.title}')));
}
});
unawaited(downloads.startClipboardWatch());
// 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리
@ -92,6 +105,11 @@ Future<void> main(List<String> args) async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final ctx = navigatorKey.currentContext;
if (ctx == null) return;
// 시작 화면을 "웹 브라우저" 로 정했으면 브라우저를 연다 (MKV 화면은 그 아래에 있음)
if (controller.settings.startScreen == 'browser') {
navigatorKey.currentState?.push(MaterialPageRoute<void>(
builder: (_) => BrowserPage(c: controller, downloads: downloads, bookmarks: bookmarks)));
}
await checkRequiredTools(ctx, services.shell);
final ctx2 = navigatorKey.currentContext;
if (ctx2 != null && ctx2.mounted) await checkForUpdate(ctx2, controller);
@ -100,6 +118,7 @@ Future<void> main(List<String> args) async {
runApp(JjCapCutApp(
controller: controller,
downloads: downloads,
bookmarks: bookmarks,
navigatorKey: navigatorKey,
messengerKey: messengerKey,
onExit: () async {
@ -111,6 +130,7 @@ Future<void> main(List<String> args) async {
class JjCapCutApp extends StatelessWidget {
final AppController controller;
final DownloadManager? downloads;
final BookmarksController? bookmarks;
final GlobalKey<NavigatorState>? navigatorKey;
final GlobalKey<ScaffoldMessengerState>? messengerKey;
final VoidCallback? onExit;
@ -119,6 +139,7 @@ class JjCapCutApp extends StatelessWidget {
super.key,
required this.controller,
this.downloads,
this.bookmarks,
this.navigatorKey,
this.messengerKey,
this.onExit,
@ -131,6 +152,6 @@ class JjCapCutApp extends StatelessWidget {
theme: buildTheme(),
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
home: HomePage(c: controller, downloads: downloads, onExit: onExit),
home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
);
}

View File

@ -240,6 +240,15 @@ class DesktopShell with WindowListener implements AppShell {
@override
String? get vlcPath => findVlc();
@override
Future<void> openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser);
@override
List<String> installedBrowsers() => [
for (final b in browserExecutables.keys)
if (findBrowser(b) != null) b,
];
// ───────── 필수 프로그램 ─────────
final _installer = ToolInstaller();

View File

@ -65,6 +65,38 @@ class ShellIntegration {
}
}
/// 외부 브라우저 실행 파일 이름
const browserExecutables = {
'chrome': 'chrome.exe',
'firefox': 'firefox.exe',
'edge': 'msedge.exe',
'whale': 'whale.exe',
};
/// 설치된 브라우저 경로 (Windows "App Paths" 등록 정보). 없으면 null.
String? findBrowser(String browser) {
final exe = browserExecutables[browser];
if (exe == null) return null;
for (final hive in ['HKCU', 'HKLM']) {
final r = Process.runSync('reg', ['query', '$hive\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\$exe', '/ve']);
if (r.exitCode != 0) continue;
final m = RegExp(r'REG_SZ\s+(.+)$', multiLine: true).firstMatch('${r.stdout}');
final path = m?.group(1)?.trim().replaceAll('"', '');
if (path != null && File(path).existsSync()) return path;
}
return null;
}
/// 웹 주소를 브라우저로 열기 ('system' 이거나 찾지 못하면 Windows 기본 브라우저)
Future<void> openInBrowser(String url, String browser) async {
final exe = browser == 'system' ? null : findBrowser(browser);
if (exe != null) {
await Process.start(exe, [url], mode: ProcessStartMode.detached);
} else {
await Process.start('explorer.exe', [url], mode: ProcessStartMode.detached);
}
}
/// 외부 플레이어 찾기
String? findVlc() {
for (final base in [

View File

@ -48,6 +48,12 @@ abstract class AppShell {
/// 설치된 VLC 경로 (없으면 null)
String? get vlcPath;
/// 웹 주소를 외부 브라우저로 열기. [browser]: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale'
Future<void> openUrl(String url, {String browser = 'system'});
/// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중)
List<String> installedBrowsers();
// ───────── 필수 프로그램 ─────────
/// 없는 필수 프로그램 설명 (예: "FFmpeg - MKV 만들기 (약 100MB)")
@ -97,6 +103,10 @@ class NoopShell implements AppShell {
@override
String? get vlcPath => null;
@override
Future<void> openUrl(String url, {String browser = 'system'}) async {}
@override
List<String> installedBrowsers() => const [];
@override
Future<List<String>> missingTools() async => const [];
@override
Future<void> installMissingTools(void Function(String name, double progress) onProgress) async {}

View File

@ -86,7 +86,11 @@ class PlatformServices {
YtDlpBackend(
ffmpegDir: ffmpegDir,
formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality),
cookieArgs: () => ytDlpCookieArgs(browser: s().ytCookiesBrowser, file: s().ytCookiesFile),
cookieArgs: () => ytDlpCookieArgs(
browser: s().ytCookiesBrowser,
file: s().ytCookiesFile,
internalProfile: s().internalCookieProfile,
internalCookieFile: s().internalCookieFile),
),
Aria2Backend(),
],

859
lib/ui/browser_page.dart Normal file
View File

@ -0,0 +1,859 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../app/app_controller.dart';
import '../app/bookmarks_controller.dart';
import '../app/download_manager.dart';
import '../core/bookmarks.dart';
import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies;
import '../core/web_address.dart';
import 'downloads_page.dart';
import 'theme.dart';
import 'work_panel.dart';
/// 브라우저 조작 (앱 안 웹뷰)
abstract class WebNav {
Future<void> load(String url);
Future<void> back();
Future<void> forward();
Future<void> reload();
Future<void> stop();
/// YouTube · Google 로그인 쿠키를 yt-dlp 용 cookies.txt 로 내보내기
Future<void> exportCookies();
}
/// 웹뷰 → 화면으로 알리는 통로
class BrowserHost {
final void Function(String url) onUrl;
final void Function(String title) onTitle;
final void Function(double progress) onProgress;
final void Function(bool hasVideo) onVideo;
final void Function(bool back, bool forward) onHistory;
final void Function(WebNav nav) attach;
const BrowserHost({
required this.onUrl,
required this.onTitle,
required this.onProgress,
required this.onVideo,
required this.onHistory,
required this.attach,
});
}
/// 웹 브라우저 (앱 안 Edge 엔진). 동영상 페이지면 상단 [다운로드] 로 바로 받는다.
class BrowserPage extends StatefulWidget {
final AppController c;
final DownloadManager? downloads;
final BookmarksController bookmarks;
final String? initialUrl;
/// 테스트용 웹뷰 대체
final Widget Function(BrowserHost host, String url)? viewBuilder;
const BrowserPage({
super.key,
required this.c,
required this.bookmarks,
this.downloads,
this.initialUrl,
this.viewBuilder,
});
@override
State<BrowserPage> createState() => _BrowserPageState();
}
class _BrowserPageState extends State<BrowserPage> {
late String _url = widget.initialUrl ?? widget.c.settings.homeUrl;
String _title = '';
double _progress = 1;
bool _hasVideo = false, _canBack = false, _canFwd = false, _panel = false;
WebNav? _nav;
/// 오른쪽 작업 현황 (화면 분할). 앱을 켜 둔 동안은 열림 여부 · 너비를 기억.
static bool? _workOpen;
static double _workWidth = 360;
late bool _work = _workOpen ?? widget.c.busy;
void _toggleWork() => setState(() => _workOpen = _work = !_work);
late final _address = TextEditingController(text: _url);
final _addressFocus = FocusNode();
late final BrowserHost _host = BrowserHost(
onUrl: (u) {
if (!mounted || u.isEmpty) return;
setState(() => _url = u);
if (!_addressFocus.hasFocus) _address.text = u;
},
onTitle: (t) => mounted ? setState(() => _title = t) : null,
onProgress: (p) => mounted ? setState(() => _progress = p) : null,
onVideo: (v) => mounted ? setState(() => _hasVideo = v) : null,
onHistory: (b, f) {
if (!mounted) return;
setState(() {
_canBack = b;
_canFwd = f;
});
},
attach: (n) => _nav = n,
);
BookmarksController get bm => widget.bookmarks;
@override
void dispose() {
_address.dispose();
_addressFocus.dispose();
super.dispose();
}
void _go(String input) {
final url = normalizeAddress(input);
_address.text = url;
_addressFocus.unfocus();
_nav?.load(url);
}
bool get _canDownload =>
widget.downloads != null && (looksLikeVideoPage(_url) || (_hasVideo && _url.startsWith('http')));
Future<void> _download() async {
// 로그인 쿠키를 먼저 내보내 yt-dlp 가 같은 로그인으로 받도록
try {
await _nav?.exportCookies();
} catch (_) {}
if (!mounted) return;
final t = widget.downloads!.addPage(_url);
final m = ScaffoldMessenger.of(context);
m.showSnackBar(SnackBar(
content: Text(t == null ? '이미 받는 중이거나 받을 수 없는 주소입니다.' : '다운로드 추가: ${_title.isEmpty ? _url : _title}'),
action: t == null
? null
: SnackBarAction(
label: '목록 보기',
onPressed: () => Navigator.push(
context, MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: widget.downloads!))),
),
));
}
void _openExternal(String url) =>
widget.c.services.shell.openUrl(url, browser: widget.c.settings.externalBrowser);
// ───────── 즐겨찾기 ─────────
Future<void> _star() async {
final existing = bm.tree.findByUrl(_url);
if (existing != null) {
await editBookmark(context, bm, existing);
return;
}
final n = bm.addLink(_title.isEmpty ? _url : _title, _url);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('즐겨찾기 표시줄에 추가: ${n.title}'),
action: SnackBarAction(label: '수정', onPressed: () => editBookmark(context, bm, n)),
));
}
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyL, control: true): () {
_addressFocus.requestFocus();
_address.selection = TextSelection(baseOffset: 0, extentOffset: _address.text.length);
},
const SingleActivator(LogicalKeyboardKey.f5): () => _nav?.reload(),
const SingleActivator(LogicalKeyboardKey.keyD, control: true): _star,
const SingleActivator(LogicalKeyboardKey.keyB, control: true, shift: true): () =>
setState(() => _panel = !_panel),
const SingleActivator(LogicalKeyboardKey.keyJ, control: true, shift: true): _toggleWork,
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _nav?.back(),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _nav?.forward(),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: ListenableBuilder(
listenable: Listenable.merge([bm, widget.c]),
builder: (context, _) => Row(children: [
if (_panel)
SizedBox(
width: 320,
child: BookmarkPanel(
bm: bm,
currentUrl: _url,
currentTitle: _title,
onOpen: (u) => _go(u),
onOpenExternal: _openExternal,
onClose: () => setState(() => _panel = false),
),
),
if (_panel) const VerticalDivider(width: 1),
Expanded(
child: Column(children: [
_toolbar(),
_BookmarkBar(bm: bm, onOpen: _go, onOpenExternal: _openExternal),
SizedBox(
height: 2,
child: _progress < 1 ? LinearProgressIndicator(value: _progress) : null,
),
Expanded(
child: widget.viewBuilder?.call(_host, _url) ??
_EdgeView(host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir),
),
]),
),
if (_work) ...[
_SplitHandle(onDrag: (dx) => setState(() {
final max = MediaQuery.sizeOf(context).width - 400;
_workWidth = (_workWidth - dx).clamp(260.0, max < 260 ? 260.0 : max);
})),
SizedBox(
width: _workWidth,
child: WorkPanel(
c: widget.c,
downloads: widget.downloads,
onClose: _toggleWork,
onOpenHome: () => Navigator.maybePop(context),
),
),
],
]),
),
),
),
);
}
Widget _toolbar() {
final starred = bm.tree.findByUrl(_url) != null;
IconButton btn(IconData i, String tip, VoidCallback? f, {Color? color}) => IconButton(
tooltip: tip,
visualDensity: VisualDensity.compact,
icon: Icon(i, size: 20, color: color),
onPressed: f,
);
return Container(
height: 48,
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(children: [
btn(Icons.video_library_outlined, 'MKV 화면으로', () => Navigator.maybePop(context), color: JjColors.accent),
const SizedBox(width: 4),
btn(Icons.arrow_back, '뒤로 (Alt+←)', _canBack ? () => _nav?.back() : null),
btn(Icons.arrow_forward, '앞으로 (Alt+→)', _canFwd ? () => _nav?.forward() : null),
_progress < 1
? btn(Icons.close, '중지', () => _nav?.stop())
: btn(Icons.refresh, '새로고침 (F5)', () => _nav?.reload()),
btn(Icons.home_outlined, '홈', () => _go(widget.c.settings.homeUrl)),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: _address,
focusNode: _addressFocus,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: JjColors.bg,
hintText: '주소 입력 또는 검색 (Ctrl+L)',
prefixIcon: Icon(_url.startsWith('https') ? Icons.lock_outline : Icons.public, size: 16),
prefixIconConstraints: const BoxConstraints(minWidth: 32),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none),
suffixIcon: IconButton(
tooltip: starred ? '즐겨찾기 수정 (Ctrl+D)' : '즐겨찾기 추가 (Ctrl+D)',
icon: Icon(starred ? Icons.star : Icons.star_border, size: 18,
color: starred ? Colors.amber : JjColors.textDim),
onPressed: _star,
),
),
onSubmitted: _go,
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
label: const Text('다운로드'),
),
const SizedBox(width: 4),
btn(Icons.open_in_new, '외부 브라우저로 열기', () => _openExternal(_url)),
if (widget.c.busy && !_work)
InkWell(
onTap: _toggleWork,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: JobIndicator(c: widget.c, compact: true),
),
),
btn(_work ? Icons.view_sidebar : Icons.view_sidebar_outlined, '작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)',
_toggleWork, color: widget.c.busy ? JjColors.accent : null),
btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, '즐겨찾기 관리 (Ctrl+Shift+B)',
() => setState(() => _panel = !_panel)),
]),
);
}
}
/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대
class _SplitHandle extends StatelessWidget {
final ValueChanged<double> onDrag;
const _SplitHandle({required this.onDrag});
@override
Widget build(BuildContext context) => MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) => onDrag(d.delta.dx),
child: Container(
width: 6,
color: JjColors.bg,
alignment: Alignment.center,
child: Container(width: 1, color: JjColors.border),
),
),
);
}
// ───────── 앱 안 Edge 웹뷰 ─────────
class _EdgeView extends StatefulWidget {
final BrowserHost host;
final String initialUrl;
final String dataDir;
const _EdgeView({required this.host, required this.initialUrl, required this.dataDir});
@override
State<_EdgeView> createState() => _EdgeViewState();
}
Future<WebViewEnvironment?>? _env;
/// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (한 번만 만들어 재사용)
Future<WebViewEnvironment?> browserEnvironment(String dir) =>
_env ??= Platform.isWindows
? WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir))
: Future.value(null);
class _EdgeViewState extends State<_EdgeView> {
@override
Widget build(BuildContext context) => FutureBuilder<WebViewEnvironment?>(
future: browserEnvironment(widget.dataDir),
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Text('웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n${snap.error}',
textAlign: TextAlign.center),
);
}
final h = widget.host;
Future<void> check(InAppWebViewController c) async {
h.onHistory(await c.canGoBack(), await c.canGoForward());
try {
final n = await c.evaluateJavascript(source: "document.querySelectorAll('video').length");
h.onVideo((n is num ? n : int.tryParse('$n') ?? 0) > 0);
} catch (_) {}
}
return InAppWebView(
webViewEnvironment: snap.data,
initialUrlRequest: URLRequest(url: WebUri(normalizeAddress(widget.initialUrl))),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
mediaPlaybackRequiresUserGesture: false,
supportMultipleWindows: false,
),
onWebViewCreated: (c) => h.attach(_InAppNav(c, snap.data, widget.dataDir)),
onLoadStart: (c, u) {
h.onUrl(u?.toString() ?? '');
h.onVideo(false);
},
onLoadStop: (c, u) async {
h.onUrl(u?.toString() ?? '');
h.onProgress(1);
await check(c);
// YouTube · Google 페이지를 보면 로그인 쿠키를 내보내 둔다 (다운로드에 사용)
final host = u?.host ?? '';
if (host.endsWith('youtube.com') || host.endsWith('google.com')) {
try {
await _InAppNav(c, snap.data, widget.dataDir).exportCookies();
} catch (_) {}
}
},
// YouTube 처럼 페이지 이동 없이 주소만 바뀌는 사이트
onUpdateVisitedHistory: (c, u, _) async {
h.onUrl(u?.toString() ?? '');
await Future<void>.delayed(const Duration(milliseconds: 800));
await check(c);
},
onTitleChanged: (c, t) => h.onTitle(t ?? ''),
onProgressChanged: (c, p) => h.onProgress(p / 100),
);
},
);
}
class _InAppNav implements WebNav {
final InAppWebViewController c;
final WebViewEnvironment? env;
final String dataDir;
_InAppNav(this.c, this.env, this.dataDir);
static const _cookieSites = [
'https://www.youtube.com/',
'https://m.youtube.com/',
'https://accounts.google.com/',
'https://www.google.com/',
];
@override
Future<void> exportCookies() async {
if (dataDir.isEmpty) return;
final cm = CookieManager.instance(webViewEnvironment: env);
final all = <CookieRecord>[];
for (final site in _cookieSites) {
for (final k in await cm.getCookies(url: WebUri(site))) {
// 만료 시각: Windows 는 초, Android 등은 밀리초로 준다 → 초로 통일
final raw = k.expiresDate ?? 0;
final exp = raw > 100000000000 ? raw ~/ 1000 : raw;
all.add(CookieRecord(
name: k.name,
value: '${k.value ?? ''}',
domain: k.domain ?? Uri.parse(site).host,
path: k.path ?? '/',
expires: (k.isSessionOnly ?? false) || exp <= 0 ? 0 : exp,
secure: k.isSecure ?? false,
httpOnly: k.isHttpOnly ?? false,
));
}
}
if (all.isEmpty) return;
// 임시 파일에 다 쓴 뒤 한 번에 바꿔 넣는다 (yt-dlp 가 쓰는 도중의 파일을 읽지 않도록)
final target = '$dataDir${Platform.pathSeparator}cookies_youtube.txt';
final tmp = File('$target.tmp');
await tmp.parent.create(recursive: true);
await tmp.writeAsString(toNetscapeCookies(all), flush: true);
await tmp.rename(target);
}
@override
Future<void> load(String url) => c.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
@override
Future<void> back() => c.goBack();
@override
Future<void> forward() => c.goForward();
@override
Future<void> reload() => c.reload();
@override
Future<void> stop() => c.stopLoading();
}
// ───────── 즐겨찾기 표시줄 (주소창 아래) ─────────
class _BookmarkBar extends StatelessWidget {
final BookmarksController bm;
final void Function(String url) onOpen;
final void Function(String url) onOpenExternal;
const _BookmarkBar({required this.bm, required this.onOpen, required this.onOpenExternal});
@override
Widget build(BuildContext context) {
final items = bm.tree.bar.children!;
return Container(
height: 32,
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 6),
child: items.isEmpty
? const Align(
alignment: Alignment.centerLeft,
child: Text(' ☆ 를 누르면 여기에 즐겨찾기가 추가됩니다',
style: TextStyle(fontSize: 11, color: JjColors.textDim)),
)
: ListView(
scrollDirection: Axis.horizontal,
children: [for (final n in items) _barItem(context, n)],
),
);
}
Widget _barItem(BuildContext context, BookmarkNode n) {
final label = n.title.length > 24 ? '${n.title.substring(0, 23)}…' : n.title;
if (n.isFolder) {
return MenuAnchor(
menuChildren: _folderMenu(n),
builder: (context, ctl, _) => TextButton.icon(
onPressed: () => ctl.isOpen ? ctl.close() : ctl.open(),
icon: const Icon(Icons.folder_outlined, size: 15),
label: Text(label, style: const TextStyle(fontSize: 12)),
),
);
}
return GestureDetector(
onSecondaryTapDown: (d) => _itemMenu(context, n, d.globalPosition),
child: Tooltip(
message: '${n.title}\n${n.url}',
waitDuration: const Duration(milliseconds: 600),
child: TextButton.icon(
onPressed: () => onOpen(n.url!),
icon: const Icon(Icons.public, size: 14),
label: Text(label, style: const TextStyle(fontSize: 12)),
),
),
);
}
List<Widget> _folderMenu(BookmarkNode f) => [
if (f.children!.isEmpty) const MenuItemButton(child: Text('(비어 있음)')),
for (final c in f.children!)
c.isFolder
? SubmenuButton(
leadingIcon: const Icon(Icons.folder_outlined, size: 16),
menuChildren: _folderMenu(c),
child: Text(c.title),
)
: MenuItemButton(
leadingIcon: const Icon(Icons.public, size: 16),
onPressed: () => onOpen(c.url!),
child: Text(c.title),
),
];
Future<void> _itemMenu(BuildContext context, BookmarkNode n, Offset at) async {
final r = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy),
items: const [
PopupMenuItem(value: 'open', child: Text('열기')),
PopupMenuItem(value: 'ext', child: Text('외부 브라우저로 열기')),
PopupMenuDivider(),
PopupMenuItem(value: 'edit', child: Text('수정')),
PopupMenuItem(value: 'del', child: Text('삭제')),
],
);
if (!context.mounted) return;
switch (r) {
case 'open':
onOpen(n.url!);
case 'ext':
onOpenExternal(n.url!);
case 'edit':
await editBookmark(context, bm, n);
case 'del':
removeBookmarkWithUndo(context, bm, n);
}
}
}
// ───────── 즐겨찾기 관리 패널 (Chrome 즐겨찾기 관리자 참고) ─────────
class BookmarkPanel extends StatefulWidget {
final BookmarksController bm;
final String currentUrl;
final String currentTitle;
final void Function(String url) onOpen;
final void Function(String url) onOpenExternal;
final VoidCallback onClose;
const BookmarkPanel({
super.key,
required this.bm,
required this.currentUrl,
required this.currentTitle,
required this.onOpen,
required this.onOpenExternal,
required this.onClose,
});
@override
State<BookmarkPanel> createState() => _BookmarkPanelState();
}
class _BookmarkPanelState extends State<BookmarkPanel> {
String _folderId = BookmarkTree.barId;
BookmarksController get bm => widget.bm;
List<BookmarkNode> _path() {
final out = <BookmarkNode>[];
var id = _folderId;
while (true) {
final n = bm.tree.find(id);
if (n == null) break;
out.insert(0, n);
final parent = bm.tree.parentOf(id);
if (parent == null) break;
id = parent.id;
}
return out;
}
@override
Widget build(BuildContext context) {
final folder = bm.tree.find(_folderId) ?? bm.tree.bar;
final items = folder.children!;
return Material(
color: JjColors.panel,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 0),
child: Row(children: [
const Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
const Text('즐겨찾기', style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(tooltip: '닫기', iconSize: 18, icon: const Icon(Icons.close), onPressed: widget.onClose),
]),
),
// 최상위 폴더 선택
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Wrap(spacing: 4, children: [
for (final r in bm.tree.roots)
ChoiceChip(
label: Text(r.title, style: const TextStyle(fontSize: 11)),
selected: _path().first.id == r.id,
onSelected: (_) => setState(() => _folderId = r.id),
),
]),
),
// 현재 폴더 경로
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
for (final (i, n) in _path().indexed) ...[
if (i > 0) const Icon(Icons.chevron_right, size: 14, color: JjColors.textDim),
InkWell(
onTap: () => setState(() => _folderId = n.id),
child: Padding(
padding: const EdgeInsets.all(2),
child: Text(n.title,
style: TextStyle(fontSize: 12, color: n.id == _folderId ? JjColors.text : JjColors.accent)),
),
),
],
]),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Wrap(spacing: 4, runSpacing: 4, children: [
OutlinedButton.icon(
onPressed: widget.currentUrl.startsWith('http')
? () => bm.addLink(widget.currentTitle.isEmpty ? widget.currentUrl : widget.currentTitle,
widget.currentUrl,
parentId: _folderId)
: null,
icon: const Icon(Icons.add, size: 16),
label: const Text('현재 페이지', style: TextStyle(fontSize: 12)),
),
OutlinedButton.icon(
onPressed: () async {
final name = await _ask(context, '새 폴더', '폴더 이름', '새 폴더');
if (name != null) bm.addFolder(name, parentId: _folderId);
},
icon: const Icon(Icons.create_new_folder_outlined, size: 16),
label: const Text('폴더', style: TextStyle(fontSize: 12)),
),
PopupMenuButton<String>(
tooltip: '다른 브라우저의 즐겨찾기 가져오기',
itemBuilder: (_) => const [
PopupMenuItem(value: 'chrome', child: Text('Chrome 에서 가져오기')),
PopupMenuItem(value: 'edge', child: Text('Edge 에서 가져오기')),
PopupMenuItem(value: 'whale', child: Text('Whale 에서 가져오기')),
],
onSelected: (b) async {
final n = await bm.importFrom(b);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n < 0
? '이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.'
: '$n개를 "기타 즐겨찾기" 에 가져왔습니다.')));
if (n > 0) setState(() => _folderId = BookmarkTree.otherId);
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.download_for_offline_outlined, size: 16),
SizedBox(width: 4),
Text('가져오기', style: TextStyle(fontSize: 12)),
]),
),
),
]),
),
const Divider(height: 12),
Expanded(
child: items.isEmpty
? const Center(child: Text('비어 있는 폴더', style: TextStyle(color: JjColors.textDim)))
: ReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: items.length,
onReorderItem: (o, n) => bm.reorder(folder.id, o, n),
itemBuilder: (_, i) {
final n = items[i];
return ListTile(
key: ValueKey(n.id),
dense: true,
leading: ReorderableDragStartListener(
index: i,
child: Icon(n.isFolder ? Icons.folder : Icons.public,
size: 18, color: n.isFolder ? Colors.amber : JjColors.textDim),
),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: n.isFolder
? Text('${n.children!.length}개', style: const TextStyle(fontSize: 11))
: Text(n.url!, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder ? setState(() => _folderId = n.id) : widget.onOpen(n.url!),
trailing: PopupMenuButton<String>(
tooltip: '더 보기',
iconSize: 18,
itemBuilder: (_) => [
if (!n.isFolder) const PopupMenuItem(value: 'ext', child: Text('외부 브라우저로 열기')),
const PopupMenuItem(value: 'edit', child: Text('수정')),
const PopupMenuItem(value: 'move', child: Text('다른 폴더로 이동')),
if (i > 0) const PopupMenuItem(value: 'up', child: Text('위로')),
if (i < items.length - 1) const PopupMenuItem(value: 'down', child: Text('아래로')),
const PopupMenuDivider(),
const PopupMenuItem(value: 'del', child: Text('삭제')),
],
onSelected: (v) async {
switch (v) {
case 'ext':
widget.onOpenExternal(n.url!);
case 'edit':
await editBookmark(context, bm, n);
case 'move':
await _moveTo(context, n);
case 'up':
bm.move(n.id, folder.id, i - 1);
case 'down':
bm.move(n.id, folder.id, i + 2);
case 'del':
removeBookmarkWithUndo(context, bm, n);
}
},
),
);
},
),
),
const Padding(
padding: EdgeInsets.all(8),
child: Text('왼쪽 아이콘을 끌어 순서를 바꿉니다',
textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
),
]),
);
}
Future<void> _moveTo(BuildContext context, BookmarkNode n) async {
final target = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text('"${n.title}" 이동'),
children: [
for (final (f, depth) in bm.tree.folders())
if (f.id != n.id && !bm.tree.isInside(f.id, n.id))
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, f.id),
child: Padding(
padding: EdgeInsets.only(left: depth * 16.0),
child: Row(children: [
const Icon(Icons.folder_outlined, size: 16),
const SizedBox(width: 6),
Text(f.title),
]),
),
),
],
),
);
if (target != null) {
final dest = bm.tree.find(target)!;
bm.move(n.id, target, dest.children!.length);
}
}
}
// ───────── 공용 대화상자 ─────────
Future<String?> _ask(BuildContext context, String title, String label, String initial) async {
final ctrl = TextEditingController(text: initial);
final r = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(title),
content: TextField(
controller: ctrl,
autofocus: true,
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
onSubmitted: (t) => Navigator.pop(ctx, t),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text('확인')),
],
),
);
await Future<void>.delayed(const Duration(milliseconds: 300)); // 닫히는 애니메이션 뒤 정리
ctrl.dispose();
return r;
}
/// 즐겨찾기 수정 (이름 · 주소, 삭제)
Future<void> editBookmark(BuildContext context, BookmarksController bm, BookmarkNode n) async {
final title = TextEditingController(text: n.title);
final url = TextEditingController(text: n.url ?? '');
final r = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(n.isFolder ? '폴더 수정' : '즐겨찾기 수정'),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(
controller: title,
autofocus: true,
decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder())),
if (!n.isFolder) ...[
const SizedBox(height: 12),
TextField(controller: url, decoration: const InputDecoration(labelText: '주소', border: OutlineInputBorder())),
],
]),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, 'del'),
child: const Text('삭제', style: TextStyle(color: JjColors.danger)),
),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: const Text('저장')),
],
),
);
if (r == 'save') bm.update(n.id, title: title.text, url: n.isFolder ? null : url.text);
if (r == 'del' && context.mounted) removeBookmarkWithUndo(context, bm, n);
await Future<void>.delayed(const Duration(milliseconds: 300));
title.dispose();
url.dispose();
}
/// 삭제 + "실행 취소"
void removeBookmarkWithUndo(BuildContext context, BookmarksController bm, BookmarkNode n) {
if (!bm.remove(n.id)) return;
// 되돌리기는 바로 보여야 하므로 앞서 떠 있던 알림은 치운다
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(
content: Text('삭제: ${n.title}'),
action: SnackBarAction(label: '실행 취소', onPressed: bm.undoRemove),
));
}

View File

@ -4,6 +4,7 @@ import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/bookmarks_controller.dart';
import '../app/download_manager.dart';
import '../core/charset_detector.dart';
import '../core/encode_options.dart';
@ -12,12 +13,15 @@ import '../core/models.dart';
import '../core/output_paths.dart';
import '../core/playlist.dart';
import 'ai_dialog.dart';
import 'browser_page.dart';
import 'downloads_page.dart';
import 'player_page.dart';
import 'settings_page.dart';
import 'subtitle_editor_page.dart';
import 'subtitle_search_dialog.dart';
import 'theme.dart';
import 'translate_dialog.dart';
import 'work_panel.dart';
class HomePage extends StatelessWidget {
final AppController c;
@ -28,7 +32,10 @@ class HomePage extends StatelessWidget {
/// 종료 버튼 (없으면 숨김)
final VoidCallback? onExit;
const HomePage({super.key, required this.c, this.downloads, this.onExit});
/// 즐겨찾기 (없으면 브라우저 버튼 숨김)
final BookmarksController? bookmarks;
const HomePage({super.key, required this.c, this.downloads, this.onExit, this.bookmarks});
@override
Widget build(BuildContext context) {
@ -52,7 +59,7 @@ class HomePage extends StatelessWidget {
},
child: Column(
children: [
_TopBar(c: c, downloads: downloads, onExit: onExit),
_TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
const Divider(height: 1),
_EncodeBar(c: c),
const Divider(height: 1),
@ -98,11 +105,12 @@ class _TopBar extends StatelessWidget {
final AppController c;
final DownloadManager? downloads;
final VoidCallback? onExit;
const _TopBar({required this.c, this.downloads, this.onExit});
final BookmarksController? bookmarks;
const _TopBar({required this.c, this.downloads, this.onExit, this.bookmarks});
@override
Widget build(BuildContext context) {
final canBuild = !c.busy && c.videos.isNotEmpty && c.ffmpegVersion != null;
final canBuild = c.videos.isNotEmpty && c.ffmpegVersion != null;
return Container(
height: 56,
color: JjColors.panel,
@ -116,7 +124,7 @@ class _TopBar extends StatelessWidget {
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
const SizedBox(width: 24),
OutlinedButton.icon(
onPressed: c.busy ? null : c.pickVideos,
onPressed: c.pickVideos,
icon: const Icon(Icons.add, size: 18),
label: const Text('동영상 추가'),
),
@ -128,6 +136,8 @@ class _TopBar extends StatelessWidget {
),
const Spacer(),
if (c.busy) ...[
JobIndicator(c: c),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: c.cancel,
icon: const Icon(Icons.stop, size: 18, color: JjColors.danger),
@ -137,14 +147,20 @@ class _TopBar extends StatelessWidget {
],
FilledButton.icon(
onPressed: canBuild ? c.buildAll : null,
icon: c.busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow, size: 20),
label: Text(c.busy ? '만드는 중…' : 'MKV 만들기'),
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
label: Text(c.busy ? 'MKV 만들기 (대기열)' : 'MKV 만들기'),
),
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 (downloads != null) ...[
const SizedBox(width: 12),
_DownloadBox(d: downloads!),
@ -363,7 +379,9 @@ class _VideoTile extends StatelessWidget {
child: Text(v.phase!,
style: const TextStyle(fontSize: 11, color: JjColors.accent)),
),
] else
] else if (v.phase != null)
Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.textDim))
else
Text(
v.status == JobStatus.failed ? '실패' : '자막 $subs개',
style: TextStyle(
@ -379,13 +397,13 @@ class _VideoTile extends StatelessWidget {
IconButton(
tooltip: '다시 만들기 대기로',
iconSize: 16,
onPressed: c.busy ? null : () => c.resetStatus(v),
onPressed: () => c.resetStatus(v),
icon: const Icon(Icons.refresh, color: JjColors.textDim),
),
IconButton(
tooltip: '목록에서 제거',
iconSize: 16,
onPressed: c.busy ? null : () => c.removeVideo(v),
onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v),
icon: const Icon(Icons.close, color: JjColors.textDim),
),
],
@ -444,7 +462,8 @@ class _VideoDetail extends StatelessWidget {
final info = v.info;
final video = info?.ofType('video').firstOrNull;
final audioCount = info?.ofType('audio').length ?? 0;
final locked = c.busy;
// 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다)
final locked = v.status == JobStatus.running;
return ListView(
padding: const EdgeInsets.all(20),
@ -617,6 +636,13 @@ class _SubtitleRow extends StatelessWidget {
: () => openSubtitleEditor(context, c, v, s),
icon: const Icon(Icons.edit_note, color: JjColors.accent),
),
if (c.services.createTranslator != null)
IconButton(
tooltip: c.canEdit(s) ? '다른 언어로 번역 (AI · 이 PC 에서)' : '이미지 자막은 번역할 수 없습니다',
iconSize: 18,
onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s),
icon: const Icon(Icons.translate, color: JjColors.accent),
),
IconButton(
tooltip: embedded ? (off ? '되살리기' : '삭제 (출력에서 제외)') : '목록에서 제거',
iconSize: 18,
@ -744,3 +770,4 @@ class _LogStrip extends StatelessWidget {
),
);
}

View File

@ -258,6 +258,54 @@ class _SettingsPageState extends State<SettingsPage> {
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [
_section('시작 · 웹 브라우저'),
ListTile(
title: const Text('처음 화면'),
subtitle: const Text('프로그램을 켰을 때 보일 화면'),
trailing: DropdownButton<String>(
value: s.startScreen,
items: const [
DropdownMenuItem(value: 'home', child: Text('MKV 화면 (기본)')),
DropdownMenuItem(value: 'browser', child: Text('웹 브라우저 (홈 주소)')),
],
onChanged: (v) => c.updateSettings((x) => x.startScreen = v!),
),
),
_textTile('홈 주소', s.homeUrl, '웹 브라우저의 시작 · 🏠 주소',
(v) => c.updateSettings((x) => x.homeUrl = v.trim().isEmpty ? 'https://www.youtube.com/' : v.trim())),
ListTile(
title: const Text('브라우저 엔진 (앱 안)'),
subtitle: const Text('Edge 엔진은 Windows 에 들어 있어 따로 설치하지 않습니다. '
'여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.'),
trailing: DropdownButton<String>(
value: s.browserEngine,
items: const [
DropdownMenuItem(value: 'edge', child: Text('Edge (내장)')),
DropdownMenuItem(value: 'chrome', enabled: false, child: Text('Chrome (다음 업데이트)',
style: TextStyle(color: JjColors.textDim))),
],
onChanged: (v) => c.updateSettings((x) => x.browserEngine = v!),
),
),
ListTile(
title: const Text('외부 브라우저'),
subtitle: const Text('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)'),
trailing: DropdownButton<String>(
value: s.externalBrowser,
items: [
const DropdownMenuItem(value: 'system', child: Text('Windows 기본 브라우저')),
for (final b in {...c.services.shell.installedBrowsers(), if (s.externalBrowser != 'system') s.externalBrowser})
DropdownMenuItem(value: b, child: Text(switch (b) {
'chrome' => 'Chrome',
'firefox' => 'Firefox',
'edge' => 'Edge',
'whale' => 'Whale',
_ => b,
})),
],
onChanged: (v) => c.updateSettings((x) => x.externalBrowser = v!),
),
),
_section('저장 위치'),
_folderTile(
title: 'MKV · 자막 (jj_mkv)',
@ -337,14 +385,17 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('YouTube 쿠키 (로봇 확인이 나올 때)'),
subtitle: Text(s.ytCookiesFile.isNotEmpty
? 'cookies.txt: ${s.ytCookiesFile}'
: s.ytCookiesBrowser.isNotEmpty
? '${s.ytCookiesBrowser} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)'
: '사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요'),
: s.ytCookiesBrowser == internalBrowserCookies
? '앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)'
: s.ytCookiesBrowser.isNotEmpty
? '${s.ytCookiesBrowser} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)'
: '사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요'),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
DropdownButton<String>(
value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser,
items: [
const DropdownMenuItem(value: '', child: Text('사용 안 함')),
const DropdownMenuItem(value: internalBrowserCookies, child: Text('앱 안 브라우저 (권장)')),
for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera'])
DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))),
const DropdownMenuItem(value: '*file', child: Text('cookies.txt 파일…')),
@ -413,6 +464,12 @@ class _SettingsPageState extends State<SettingsPage> {
]),
),
),
SwitchListTile(
value: s.addFinishedDownloads,
onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v),
title: const Text('다 받은 동영상을 편집 목록에 자동 추가'),
subtitle: const Text('브라우저에서 받은 영상도 바로 자막 만들기 · MKV 만들기를 할 수 있게 목록에 넣습니다'),
),
_section('AI 자막'),
SwitchListTile(
value: s.askAiOptions,

View File

@ -32,6 +32,9 @@ class _SearchDialogState extends State<_SearchDialog> {
String? _hash;
bool _useHash = true;
bool _busy = false;
/// 받은 자막을 한국어로 번역해 함께 추가
bool _translateKo = true;
String? _error;
List<SubtitleSearchResult>? _results;
final Set<SubtitleSearchResult> _picked = {};
@ -88,13 +91,16 @@ class _SearchDialogState extends State<_SearchDialog> {
Future<void> _download() async {
setState(() => _busy = true);
final n = await c.downloadSubtitles(widget.v, _picked.toList());
final ko = languageOf('ko');
final tr = _translateKo && c.services.createTranslator != null && _picked.any((r) => r.language.code != ko.code);
final n = await c.downloadSubtitles(widget.v, _picked.toList(), translateTo: tr ? ko : null);
if (!mounted) return;
setState(() => _busy = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n == _picked.length
? '자막 $n개를 받아 MKV 자막 목록에 추가했습니다.'
: '자막 ${_picked.length}개 중 $n개를 받았습니다. 아래 작업 기록을 확인하세요.')));
content: Text((n == _picked.length
? '자막 $n개를 받아 MKV 자막 목록에 추가했습니다.'
: '자막 ${_picked.length}개 중 $n개를 받았습니다. 아래 작업 기록을 확인하세요.') +
(tr && n > 0 ? '\n한국어 번역은 대기열에서 이어서 진행합니다.' : ''))));
if (n > 0) Navigator.pop(context);
}
@ -124,6 +130,15 @@ class _SearchDialogState extends State<_SearchDialog> {
]),
),
actions: [
if (ready && c.services.createTranslator != null)
Row(mainAxisSize: MainAxisSize.min, children: [
Checkbox(value: _translateKo, onChanged: (x) => setState(() => _translateKo = x ?? false)),
GestureDetector(
onTap: () => setState(() => _translateKo = !_translateKo),
child: const Text('받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)', style: TextStyle(fontSize: 12)),
),
const SizedBox(width: 12),
]),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('닫기')),
if (ready)
FilledButton.icon(

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../core/languages.dart';
import '../core/models.dart';
import '../services/model_store.dart';
import 'theme.dart';
/// 있는 자막 (예: 영어) 을 다른 언어로 번역해 MKV 자막에 추가하는 창
Future<void> showTranslateDialog(BuildContext context, AppController c, VideoItem v, SubtitleEntry s) async {
final targets = await showDialog<Set<Language>>(
context: context,
builder: (_) => _TranslateDialog(c: c, s: s),
);
if (targets == null || targets.isEmpty) return;
await c.translateSubtitle(v, s, targets);
}
/// 기본으로 고를 언어: 한국어 (원어가 한국어면 영어)
Set<Language> defaultTranslateTargets(Language source) {
final ko = languages.firstWhere((l) => l.code == 'ko');
final en = languages.firstWhere((l) => l.code == 'en');
return {source.code == 'ko' ? en : ko};
}
class _TranslateDialog extends StatefulWidget {
final AppController c;
final SubtitleEntry s;
const _TranslateDialog({required this.c, required this.s});
@override
State<_TranslateDialog> createState() => _TranslateDialogState();
}
class _TranslateDialogState extends State<_TranslateDialog> {
late Set<Language> _targets = defaultTranslateTargets(widget.s.language);
bool? _installed;
@override
void initState() {
super.initState();
widget.c.services.models.isInstalled(nllbModel).then((ok) {
if (mounted) setState(() => _installed = ok);
});
}
@override
Widget build(BuildContext context) {
final src = widget.s.language;
final extra = languages.where((l) => !_targets.contains(l) && l.code != src.code).toList();
return AlertDialog(
title: const Text('자막 번역'),
content: SizedBox(
width: 480,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.s.displayName, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
src == undetermined
? '원어: 자동 감지 (글자로 추정)'
: '원어: ${src.name} (${src.code})',
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 16),
const Text('번역할 언어 (jj_mkv\\파일명_언어코드.srt 로 저장 후 MKV 에 추가)',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 6, children: [
for (final l in _targets)
InputChip(
label: Text('${l.name} (${l.code})'),
onDeleted: _targets.length > 1 ? () => setState(() => _targets = {..._targets}..remove(l)) : null,
),
PopupMenuButton<Language>(
tooltip: '언어 추가',
itemBuilder: (_) => [
for (final l in extra) PopupMenuItem(value: l, child: Text('${l.name} (${l.code})')),
],
onSelected: (l) => setState(() => _targets = {..._targets, l}),
child: const Chip(avatar: Icon(Icons.add, size: 16), label: Text('언어 추가')),
),
]),
const SizedBox(height: 16),
Row(children: [
const Text('번역 모델: NLLB-200 (이 PC 에서만) ', style: TextStyle(fontSize: 13)),
if (_installed != null)
Text(_installed! ? '설치됨' : '처음 사용 시 내려받기 ${nllbModel.sizeLabel}',
style: TextStyle(fontSize: 11, color: _installed! ? JjColors.success : JjColors.textDim)),
]),
const SizedBox(height: 8),
const Text('※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.',
style: TextStyle(fontSize: 11, color: JjColors.textDim)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')),
FilledButton.icon(
onPressed: () => Navigator.pop(context, _targets),
icon: const Icon(Icons.translate, size: 18),
label: const Text('번역 시작'),
),
],
);
}
}

236
lib/ui/work_panel.dart Normal file
View File

@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/download_manager.dart';
import '../core/models.dart';
import '../services/downloader.dart';
import 'downloads_page.dart';
import 'theme.dart';
/// 브라우저 오른쪽 "작업 현황" (화면 분할): 자막 · MKV 작업과 다운로드 진행을 보면서 인터넷을 볼 수 있게.
class WorkPanel extends StatelessWidget {
final AppController c;
final DownloadManager? downloads;
final VoidCallback onClose;
/// MKV (편집) 화면으로 돌아가기
final VoidCallback onOpenHome;
const WorkPanel({
super.key,
required this.c,
required this.downloads,
required this.onClose,
required this.onOpenHome,
});
@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: [
..._jobs(),
_title('편집 목록 ${c.videos.length}개'),
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),
],
]),
),
]),
),
);
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),
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: '모든 작업 취소',
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),
]),
);
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),
];
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,
]),
);
}
class _Dim extends StatelessWidget {
final String text;
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)),
);
}
class _VideoRow extends StatelessWidget {
final VideoItem v;
const _VideoRow({required this.v});
@override
Widget build(BuildContext context) {
final (icon, color) = switch (v.status) {
JobStatus.ready => (Icons.movie_outlined, JjColors.textDim),
JobStatus.running => (Icons.autorenew, JjColors.accent),
JobStatus.done => (Icons.check_circle, JjColors.success),
JobStatus.failed => (Icons.error, JjColors.danger),
};
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}개',
};
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)),
]),
),
]),
);
}
}
class _DownloadRow extends StatelessWidget {
final AppController c;
final DownloadTask t;
const _DownloadRow({required this.c, required this.t});
@override
Widget build(BuildContext context) {
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()));
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),
),
]),
);
}
}
/// 지금 하는 작업 + 대기 개수 (상단 바 · 브라우저에서 같이 씀)
class JobIndicator extends StatelessWidget {
final AppController c;
final bool compact;
const JobIndicator({super.key, required this.c, this.compact = 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 wait = c.pendingJobs.length;
return Tooltip(
message: [
'지금: ${c.currentJob ?? ''}',
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(
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),
),
),
]),
);
}
}

View File

@ -243,6 +243,70 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
url: "https://pub.dev"
source: hosted
version: "1.3.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev"
source: hosted
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
flutter_lints:
dependency: "direct dev"
description:

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.2+3
version: 1.1.0+4
environment:
sdk: ^3.13.4
@ -54,6 +54,7 @@ dependencies:
screen_retriever: ^0.2.2
package_info_plus: ^10.2.1
crypto: ^3.0.7
flutter_inappwebview: ^6.1.5
dev_dependencies:
flutter_test:
sdk: flutter

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
@ -32,6 +33,7 @@ class _FakeRecognizer implements SpeechRecognizer {
class _FakeTranslator implements Translator {
final calls = <String>[];
Completer<void>? gate;
bool disposed = false;
@override
Future<void> load(String modelDir) async {}
@ -39,6 +41,7 @@ class _FakeTranslator implements Translator {
Future<List<String>> translate(List<String> lines,
{required String source, required String target, AiProgress? onProgress}) async {
calls.add('$source>$target');
await gate?.future;
onProgress?.call(1);
return [for (final l in lines) '[$target] $l'];
}
@ -138,4 +141,59 @@ void main() {
await c.generateAiSubtitles([v], AiOptions.defaults());
expect(v.subtitles.where((s) => s.kind == SubtitleKind.external), hasLength(3));
});
test('있는 자막 번역: 영어 SRT → _ko.srt (MKV 목록에 추가, 덮어쓰지 않음) + 작업 대기열', () async {
final dir = Directory.systemTemp.createTempSync('jj_tr_');
addTearDown(() => dir.deleteSync(recursive: true));
final video = p.join(dir.path, 'movie.mp4');
File(video).writeAsStringSync('x');
final en = p.join(dir.path, 'movie.en.srt');
File(en).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\nHello there.\n\n'
'2\n00:00:03,000 --> 00:00:04,000\nThis is\na test.\n\n');
final models = ModelStore(p.join(dir.path, 'models'));
for (var i = 0; i < nllbModel.files.length; i++) {
File(await models.pathOf(nllbModel, i))
..createSync(recursive: true)
..writeAsStringSync('x');
}
final tr = _FakeTranslator()..gate = Completer<void>();
final c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('ffmpeg', 'ffprobe'),
storage: DesktopStorageService(),
createTranslator: () => tr,
models: models,
));
// 이미 있는 _ko.srt 는 덮어쓰지 않는다
Directory(p.join(dir.path, 'jj_mkv')).createSync();
File(p.join(dir.path, 'jj_mkv', 'movie_ko.srt')).writeAsStringSync('old');
final v = VideoItem(video);
final s = SubtitleEntry.external(path: en, language: undetermined, charset: 'UTF-8');
v.subtitles.add(s);
final first = c.translateSubtitle(v, s, {languageOf('ko')});
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(c.busy, isTrue);
expect(c.currentJob, contains('자막 번역'));
// 번역 중에 하나 더 → 대기열
final second = c.translateSubtitle(v, s, {languageOf('ja')});
expect(c.pendingJobs, hasLength(1));
tr.gate!.complete();
await first;
await second;
expect(c.busy, isFalse);
expect(c.pendingJobs, isEmpty);
// 원어 자동 판별 (영어) → 한국어, 일본어 순서대로
expect(tr.calls, ['eng_Latn>kor_Hang', 'eng_Latn>jpn_Jpan']);
expect(v.status, JobStatus.ready, reason: v.message);
final added = v.subtitles.skip(1).toList();
expect(added.map((x) => p.basename(x.path!)), ['movie_ko_2.srt', 'movie_ja.srt']);
expect(added.first.language.code, 'ko');
expect(added.first.title, contains('AI 번역'));
final ko = parseSrt(File(added.first.path!).readAsStringSync());
expect(ko.map((x) => x.text), ['[kor_Hang] Hello there.', '[kor_Hang] This is a test.']);
expect(ko[1].start, const Duration(seconds: 3));
expect(File(p.join(dir.path, 'jj_mkv', 'movie_ko.srt')).readAsStringSync(), 'old');
});
}

275
test/browser_test.dart Normal file
View File

@ -0,0 +1,275 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/bookmarks.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/core/web_address.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/downloader.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:path/path.dart' as p;
class _Backend implements DownloadBackend {
@override
final DownloadKind kind;
final started = <String>[];
void Function()? changed;
_Backend(this.kind);
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
this.changed = changed;
started.add(t.source);
t.state = DownloadState.downloading;
}
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> shutdown() async {}
}
class _Nav implements WebNav {
final loads = <String>[];
@override
Future<void> load(String url) async => loads.add(url);
@override
Future<void> back() async {}
@override
Future<void> forward() async {}
@override
Future<void> reload() async {}
@override
Future<void> stop() async {}
var exported = 0;
@override
Future<void> exportCookies() async => exported++;
}
void main() {
group('즐겨찾기 트리', () {
test('추가 · 찾기 · 이동 · 삭제', () {
final t = BookmarkTree.defaults();
final f = t.add(BookmarkTree.barId, BookmarkNode.folder('f', '음악'));
t.add('f', BookmarkNode.link('a', 'A', 'https://a.com/'));
expect(t.findByUrl('https://a.com'), isNotNull); // 끝의 / 무시
expect(t.parentOf('a')!.id, 'f');
// 같은 폴더 안: 맨 앞 → 맨 뒤
final first = t.bar.children!.first.id;
expect(t.move(first, BookmarkTree.barId, t.bar.children!.length), isTrue);
expect(t.bar.children!.last.id, first);
// 폴더를 자기 안으로는 못 옮김
final sub = t.add('f', BookmarkNode.folder('s', '하위'));
expect(t.move('f', sub.id, 0), isFalse);
expect(t.move('a', BookmarkTree.otherId, 0), isTrue);
expect(t.other.children!.single.id, 'a');
final r = t.remove(f.id)!;
expect(r.$2, BookmarkTree.barId);
expect(t.find('s'), isNull);
expect(t.remove(BookmarkTree.barId), isNull); // 기본 폴더는 못 지움
});
test('JSON 저장 · 불러오기', () {
final t = BookmarkTree.defaults()..add(BookmarkTree.otherId, BookmarkNode.folder('x', 'X', [BookmarkNode.link('y', 'Y', 'https://y')]));
final back = BookmarkTree.fromJson(t.toJson());
expect(back.find('y')!.url, 'https://y');
expect(back.bar.children!.map((n) => n.title), t.bar.children!.map((n) => n.title));
});
test('Chrome 즐겨찾기 가져오기', () {
final t = BookmarkTree.defaults();
final n = t.importChromium({
'roots': {
'bookmark_bar': {
'type': 'folder', 'name': '북마크바',
'children': [
{'type': 'url', 'name': '네이버', 'url': 'https://naver.com'},
{'type': 'url', 'name': '설정', 'url': 'chrome://settings'}, // 제외
{'type': 'folder', 'name': '영상', 'children': [
{'type': 'url', 'name': 'YT', 'url': 'https://youtube.com'},
]},
],
},
'other': {'type': 'folder', 'name': '기타', 'children': []},
},
}, 'Chrome 에서 가져옴');
expect(n, 2);
final imported = t.other.children!.single;
expect(imported.title, 'Chrome 에서 가져옴');
expect(t.findByUrl('https://youtube.com')!.title, 'YT');
});
});
group('주소 · 동영상 판별 · 쿠키', () {
test('주소창 입력', () {
expect(normalizeAddress('youtube.com'), 'https://youtube.com');
expect(normalizeAddress('http://a.b/c'), 'http://a.b/c');
expect(normalizeAddress('localhost:8080/x'), 'https://localhost:8080/x');
expect(normalizeAddress('자막 만드는 법'), startsWith('https://www.google.com/search?q='));
});
test('동영상 페이지', () {
expect(looksLikeVideoPage('https://www.youtube.com/watch?v=abc'), isTrue);
expect(looksLikeVideoPage('https://vimeo.com/12345'), isTrue);
expect(looksLikeVideoPage('https://tv.naver.com/v/123'), isTrue);
expect(looksLikeVideoPage('https://www.google.com/'), isFalse);
});
test('앱 안 브라우저 쿠키 → yt-dlp', () {
expect(ytDlpCookieArgs(browser: internalBrowserCookies, internalProfile: r'C:\w\EBWebView\Default'),
['--cookies-from-browser', r'edge:C:\w\EBWebView\Default']);
expect(ytDlpCookieArgs(browser: internalBrowserCookies), isEmpty); // 아직 로그인 전
expect(AppSettings().ytCookiesBrowser, internalBrowserCookies); // 새 설치 기본값
});
test('cookies.txt 형식 (HttpOnly · 중복 제거 · 쿠키 파일 우선)', () {
final txt = toNetscapeCookies(const [
CookieRecord(name: 'SID', value: 'a\tb', domain: '.youtube.com', expires: 1800000000, secure: true, httpOnly: true),
CookieRecord(name: 'SID', value: 'dup', domain: '.youtube.com'),
CookieRecord(name: 'X', value: '1', domain: 'www.google.com'),
]);
final lines = txt.split('\n').where((l) => l.isNotEmpty && !l.startsWith('# ')).toList();
expect(lines, [
'#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\t1800000000\tSID\tab',
'www.google.com\tFALSE\t/\tFALSE\t0\tX\t1',
]);
expect(txt, startsWith('# Netscape HTTP Cookie File'));
expect(
ytDlpCookieArgs(browser: internalBrowserCookies, internalProfile: r'C:\p', internalCookieFile: r'C:\c.txt'),
['--cookies', r'C:\c.txt']);
});
test('브라우저 페이지 받기 (YouTube 외 사이트도)', () {
final v = _Backend(DownloadKind.video);
final d = DownloadManager(backends: [v], settings: () => AppSettings()..downloadRoot = r'D:\dl');
expect(d.addPage('https://vimeo.com/12345')!.kind, DownloadKind.video);
expect(d.addPage('https://vimeo.com/12345'), isNull); // 이미 받는 중
expect(d.addPage('about:blank'), isNull);
expect(d.addPage('https://youtu.be/abc')!.source, 'https://youtu.be/abc');
d.dispose();
});
test('다 받은 영상 → 편집 목록 알림 (한 번만, 조각 파일 제외)', () async {
final v = _Backend(DownloadKind.video);
final d = DownloadManager(backends: [v], settings: () => AppSettings()..downloadRoot = r'D:\dl');
final got = <String>[];
d.onFinished.listen((t) => got.add(t.id));
final t = d.addPage('https://vimeo.com/1')!;
t.files.addAll([r'D:\dl\a.f137.mp4', r'D:\dl\a.mp4.part', r'D:\dl\a.mp4', r'D:\dl\a.en.vtt']);
expect(DownloadManager.videoFilesOf(t, exists: (_) => true), [r'D:\dl\a.mp4']);
t.state = DownloadState.done;
v.changed!(); // 백엔드가 "다 받음" 알림
v.changed!(); // 두 번 알려도 한 번만
await Future<void>.delayed(Duration.zero);
expect(got, [t.id]);
d.dispose();
});
});
testWidgets('브라우저: ☆ 추가 · 표시줄 · 다운로드 버튼 · 관리 패널 (삭제 · 실행 취소)', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_bm_');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));
final video = _Backend(DownloadKind.video);
final d = DownloadManager(backends: [video], settings: () => c.settings, readClipboard: () async => null);
final nav = _Nav();
late BrowserHost host;
await tester.pumpWidget(MaterialApp(
home: BrowserPage(
c: c,
bookmarks: bm,
downloads: d,
initialUrl: 'https://www.google.com/',
viewBuilder: (h, url) {
host = h;
h.attach(nav);
return const ColoredBox(color: Colors.white);
},
),
));
// 기본 즐겨찾기 표시줄
expect(find.text('YouTube'), findsOneWidget);
FilledButton dl() => tester.widget<FilledButton>(find.ancestor(of: find.text('다운로드'), matching: find.byWidgetPredicate((w) => w is FilledButton)));
expect(dl().onPressed, isNull); // 동영상 페이지 아님
// 주소창 입력 → 이동
await tester.enterText(find.byType(TextField).first, 'youtube.com/watch?v=abc');
await tester.testTextInput.receiveAction(TextInputAction.done);
expect(nav.loads.last, 'https://youtube.com/watch?v=abc');
host.onUrl('https://www.youtube.com/watch?v=abc');
host.onTitle('재미있는 영상');
await tester.pump();
expect(dl().onPressed, isNotNull);
await tester.tap(find.text('다운로드'));
await tester.pump();
expect(video.started, ['https://www.youtube.com/watch?v=abc']);
// ☆ → 표시줄에 추가
await tester.tap(find.byTooltip('즐겨찾기 추가 (Ctrl+D)'));
await tester.pumpAndSettle();
expect(find.text('재미있는 영상'), findsOneWidget);
expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull);
// 관리 패널 → 삭제 → 실행 취소
await tester.tap(find.byTooltip('즐겨찾기 관리 (Ctrl+Shift+B)'));
await tester.pumpAndSettle();
expect(find.text('즐겨찾기'), findsWidgets);
await tester.tap(find.byTooltip('더 보기').last);
await tester.pumpAndSettle();
await tester.tap(find.text('삭제').last);
await tester.pumpAndSettle();
expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNull);
await tester.tap(find.text('실행 취소'));
await tester.pumpAndSettle();
expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull);
// 패널에서 누르면 그 주소로 이동
await tester.tap(find.text('OpenSubtitles').last);
expect(nav.loads.last, 'https://www.opensubtitles.com/');
// 화면 분할: 작업 현황 (다운로드 진행 · 편집 목록)
await tester.tap(find.byTooltip('작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)'));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('작업 현황'), findsOneWidget);
expect(find.text('쉬는 중'), findsOneWidget);
expect(find.text('다운로드 1개'), findsOneWidget);
expect(find.text('재미있는 영상'), findsWidgets);
await tester.tap(find.byTooltip('작업 현황 닫기'));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('작업 현황'), findsNothing);
d.dispose();
});
test('즐겨찾기 파일 저장 · 다시 불러오기 · 순서 바꾸기', () async {
final dir = Directory.systemTemp.createTempSync('jj_bm2_');
addTearDown(() => dir.deleteSync(recursive: true));
final file = p.join(dir.path, 'bookmarks.json');
final a = BookmarksController(file);
await a.load();
a.addFolder('영상');
a.addLink('B', 'https://b.com');
final ids = a.tree.bar.children!.map((n) => n.title).toList();
a.reorder(BookmarkTree.barId, ids.length - 1, 0); // 맨 뒤 → 맨 앞
await Future<void>.delayed(const Duration(milliseconds: 300));
final b = BookmarksController(file);
await b.load();
expect(b.tree.bar.children!.first.title, 'B');
expect(b.tree.bar.children!.any((n) => n.isFolder && n.title == '영상'), isTrue);
});
}

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
@ -16,6 +17,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
flutter_inappwebview_windows
hotkey_manager_windows
media_kit_libs_windows_video
media_kit_video