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
parent
f5f0098018
commit
960a695c7e
|
|
@ -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 <이 저장소>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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 로 받을 수 있는 동영상 페이지로 보이는지 (주소만으로 판단).
|
||||
/// 페이지 안에 <video> 가 있는지는 브라우저가 따로 확인한다.
|
||||
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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
));
|
||||
}
|
||||
|
|
@ -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 {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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('번역 시작'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
pubspec.lock
64
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue