v1.0.1: 자동 업데이트, YouTube 재생목록 · 형식 · 화질, 동시 작업 수

- GitHub 최신 Release 확인 · SHA256 검증 · 종료 후 교체 · 다시 시작 (받은 파일 · 모델 · 설정 보존)
- YouTube 재생목록 전체 받기, 형식(MP4 · WebM · MP3 · M4A) · 화질 · 쿠키 설정
- 동시 다운로드 수 · 동시 MKV 변환 수 (대기열)
- 다운로드 진행 막대에 퍼센트, 작업 기록 창 숨기기
- AV1 하드웨어 디코딩 미지원 시 거짓 "Could not open codec" 오류 표시 수정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
old-history-local
RyuJongJin 2026-09-30 13:29:01 +09:00
parent 6d6e1f83db
commit cc6974fc30
28 changed files with 1624 additions and 62 deletions

View File

@ -0,0 +1,33 @@
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/platform/common/media_kit_full_player.dart';
import 'package:jj_mkvmaker/platform/common/media_kit_player.dart';
/// AV1 (하드웨어 디코딩 미지원 그래픽카드) 파일: 오류 알림 없이 재생되는지 (JJ_AV1_FILE)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('AV1 재생 · 오류 알림 없음', (tester) async {
final file = Platform.environment['JJ_AV1_FILE'];
if (file == null || !File(file).existsSync()) return;
MediaKitPreviewPlayer.ensureInitialized();
final pl = MediaKitFullPlayer();
final errors = <String>[];
pl.errors.listen(errors.add);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: pl.buildView())));
await tester.runAsync(() => pl.open([file]));
for (var i = 0; i < 60; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
}
final s = pl.state.value;
// ignore: avoid_print
print('RESULT 위치 ${s.position.inMilliseconds}ms 음성트랙 ${s.audioTracks.length} 오류 $errors');
expect(s.position, greaterThan(const Duration(seconds: 4)));
expect(errors, isEmpty);
await tester.runAsync(() => pl.dispose());
});
}

View File

@ -61,6 +61,9 @@ class AppController extends ChangeNotifier {
AppController(this.services, {this.settingsStore}); AppController(this.services, {this.settingsStore});
/// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용
Future<bool> Function()? confirmQuit;
/// 설정 변경 후 저장·적용 /// 설정 변경 후 저장·적용
Future<void> updateSettings(void Function(AppSettings s) change) async { Future<void> updateSettings(void Function(AppSettings s) change) async {
change(settings); change(settings);
@ -650,12 +653,21 @@ class AppController extends ChangeNotifier {
Future<void> buildAll() async { Future<void> buildAll() async {
if (busy || ffmpegVersion == null) return; if (busy || ffmpegVersion == null) return;
busy = true; busy = true;
_buildCancelled = false;
notifyListeners(); notifyListeners();
try { try {
for (final v in videos) { // 동시에 [maxParallelJobs] 개씩 (0 = 무제한). 작업자가 목록에서 하나씩 가져가 처리한다.
if (v.status == JobStatus.done) continue; final queue = videos.where((v) => v.status != JobStatus.done).toList();
await _build(v); final limit = settings.maxParallelJobs <= 0 ? queue.length : settings.maxParallelJobs;
if (queue.length > 1) _log('MKV 만들기: ${queue.length}개, 동시에 ${limit.clamp(1, queue.length)}개씩');
var next = 0;
Future<void> worker() async {
while (!_buildCancelled && next < queue.length) {
await _build(queue[next++]);
}
} }
await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]);
final ok = videos.where((v) => v.status == JobStatus.done).length; final ok = videos.where((v) => v.status == JobStatus.done).length;
_log('완료: 성공 $ok / 전체 ${videos.length}'); _log('완료: 성공 $ok / 전체 ${videos.length}');
} finally { } finally {
@ -701,7 +713,10 @@ class AppController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
bool _buildCancelled = false;
void cancel() { void cancel() {
_buildCancelled = true;
_aiCancelled = true; _aiCancelled = true;
_translator?.cancel(); _translator?.cancel();
_tool.cancel(); _tool.cancel();

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../core/download_detect.dart'; import '../core/download_detect.dart';
import '../services/downloader.dart'; import '../services/downloader.dart';
@ -100,11 +101,81 @@ class DownloadManager extends ChangeNotifier {
); );
tasks.insert(0, t); tasks.insert(0, t);
_added.add(t); _added.add(t);
// YouTube 재생목록: 목록을 읽어 영상마다 한 줄씩 바꿔 넣는다
if (link.kind == DownloadKind.video && s.ytExpandPlaylists && youtubePlaylistId(link.url) != null) {
t
..expanding = true
..title = '재생목록 불러오는 중… ${link.url}';
notifyListeners();
unawaited(_expand(t, backend));
return t;
}
notifyListeners(); notifyListeners();
unawaited(backend.start(t, notifyListeners)); _pump();
return t; return t;
} }
Future<void> _expand(DownloadTask holder, DownloadBackend backend) async {
try {
final r = await backend.expandPlaylist(holder.source);
final i = tasks.indexOf(holder);
if (i < 0) return; // 불러오는 동안 삭제됨
if (r == null || r.$2.isEmpty) {
// 재생목록이 아니면 영상 하나로 받음
holder
..expanding = false
..title = holder.source;
_changed();
return;
}
final (name, entries) = r;
final folder = safeFolderName(name);
final dir = p.join(settings().ytDlpDir, folder.isEmpty ? 'playlist' : folder);
final have = tasks.where((x) => x.state != DownloadState.cancelled).map((x) => x.source).toSet();
final items = <DownloadTask>[];
for (var k = 0; k < entries.length; k++) {
final e = entries[k];
if (have.contains(e.url)) continue;
items.add(DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: e.url, dir: dir)
..title = '[${k + 1}/${entries.length}] ${e.title}'
..extra['playlist'] = name);
}
tasks
..removeAt(i)
..insertAll(i, items);
selected.remove(holder.id);
_changed();
} catch (e) {
holder
..expanding = false
..state = DownloadState.failed
..error = '$e';
notifyListeners();
}
}
/// 상태가 바뀔 때: 화면 갱신 + 대기 중인 다음 작업 시작
void _changed() {
notifyListeners();
_pump();
}
/// 동시 다운로드 수 안에서 대기 중인 작업을 들어온 순서대로 시작
void _pump() {
final limit = settings().maxParallelDownloads;
var running = tasks.where((t) => t.state == DownloadState.downloading).length;
final waiting = tasks.where((t) => t.state == DownloadState.queued && !t.expanding).toList()
..sort((a, b) => int.parse(a.id).compareTo(int.parse(b.id)));
for (final t in waiting) {
if (limit > 0 && running >= limit) break;
final backend = backends[t.kind];
if (backend == null) continue;
t.state = DownloadState.downloading;
running++;
unawaited(backend.start(t, _changed));
}
}
Iterable<DownloadTask> get _sel => tasks.where((t) => selected.contains(t.id)); Iterable<DownloadTask> get _sel => tasks.where((t) => selected.contains(t.id));
void toggle(DownloadTask t) { void toggle(DownloadTask t) {
@ -130,17 +201,20 @@ class DownloadManager extends ChangeNotifier {
await backends[t.kind]!.pause(t); await backends[t.kind]!.pause(t);
} }
} }
notifyListeners(); _changed();
} }
/// 다시 받기: 대기열에 넣고 차례가 되면 시작
Future<void> resume(Iterable<DownloadTask> list) async { Future<void> resume(Iterable<DownloadTask> list) async {
for (final t in list.toList()) { for (final t in list.toList()) {
if (t.state == DownloadState.paused || t.state == DownloadState.failed) { if (t.state == DownloadState.paused || t.state == DownloadState.failed) {
if (t.state == DownloadState.failed) t.extra.remove('gid'); // 실패한 aria2 작업은 새로 추가 if (t.state == DownloadState.failed) t.extra.remove('gid'); // 실패한 aria2 작업은 새로 추가
unawaited(backends[t.kind]!.start(t, notifyListeners)); t
..state = DownloadState.queued
..error = null;
} }
} }
notifyListeners(); _changed();
} }
/// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김) /// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김)
@ -148,7 +222,7 @@ class DownloadManager extends ChangeNotifier {
for (final t in list.toList()) { for (final t in list.toList()) {
if (t.unfinished) await backends[t.kind]!.cancel(t); if (t.unfinished) await backends[t.kind]!.cancel(t);
} }
notifyListeners(); _changed();
} }
/// 목록에서 삭제 (진행 중이면 취소 후 삭제) /// 목록에서 삭제 (진행 중이면 취소 후 삭제)

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../core/download_detect.dart';
import '../core/encode_options.dart'; import '../core/encode_options.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
@ -32,6 +33,33 @@ class AppSettings {
String openSubtitlesUser = ''; String openSubtitlesUser = '';
String openSubtitlesPassword = ''; String openSubtitlesPassword = '';
/// YouTube 받을 형식 · 화질 (기본: MP4 최고 화질)
YtContainer ytContainer = YtContainer.mp4;
YtQuality ytQuality = YtQuality.best;
/// 재생목록 주소면 목록 전체를 받기
bool ytExpandPlaylists = true;
/// YouTube 로봇 확인 대응: 쿠키를 가져올 브라우저 (빈 값 = 사용 안 함) 또는 cookies.txt 경로
String ytCookiesBrowser = '';
String ytCookiesFile = '';
/// 동시에 받는 다운로드 수 (0 = 무제한)
int maxParallelDownloads = 3;
/// 동시에 만드는 MKV 수 (0 = 무제한)
int maxParallelJobs = 5;
/// 메인 화면 아래 작업 기록 창 보이기
bool showLog = true;
/// 시작할 때 새 버전 확인 (하루 한 번)
bool autoCheckUpdates = true;
String lastUpdateCheck = '';
/// "이 버전 건너뛰기" 한 버전
String skippedVersion = '';
/// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈) /// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈)
PlaylistMode playlistMode = PlaylistMode.series; PlaylistMode playlistMode = PlaylistMode.series;
@ -58,6 +86,17 @@ class AppSettings {
'openSubtitlesKey': openSubtitlesKey, 'openSubtitlesKey': openSubtitlesKey,
'openSubtitlesUser': openSubtitlesUser, 'openSubtitlesUser': openSubtitlesUser,
'openSubtitlesPassword': openSubtitlesPassword, 'openSubtitlesPassword': openSubtitlesPassword,
'ytContainer': ytContainer.name,
'ytQuality': ytQuality.name,
'ytExpandPlaylists': ytExpandPlaylists,
'ytCookiesBrowser': ytCookiesBrowser,
'ytCookiesFile': ytCookiesFile,
'maxParallelDownloads': maxParallelDownloads,
'maxParallelJobs': maxParallelJobs,
'showLog': showLog,
'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck,
'skippedVersion': skippedVersion,
'playlistMode': playlistMode.name, 'playlistMode': playlistMode.name,
'externalPlayers': externalPlayers, 'externalPlayers': externalPlayers,
'encode': { 'encode': {
@ -84,6 +123,17 @@ class AppSettings {
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? '' ..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? '' ..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
..openSubtitlesPassword = j['openSubtitlesPassword'] 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? ?? ''
..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
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
..skippedVersion = j['skippedVersion'] as String? ?? ''
..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series) ..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series)
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>() ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>()
..encode = EncodeSettings( ..encode = EncodeSettings(

88
lib/core/app_update.dart Normal file
View File

@ -0,0 +1,88 @@
/// GitHub 저장소 (업데이트 확인)
const updateRepo = 'RyuJongJin/JJ_MKVMaker';
/// "v1.2.3" · "1.2.3+4" → [1, 2, 3] (숫자가 아닌 부분 무시)
List<int> parseVersion(String v) {
final core = v.trim().replaceFirst(RegExp(r'^[vV]'), '').split(RegExp(r'[+\-\s]')).first;
return [for (final part in core.split('.')) int.tryParse(part) ?? 0];
}
/// a < b 면 음수, 같으면 0, a > b 면 양수
int compareVersions(String a, String b) {
final x = parseVersion(a), y = parseVersion(b);
for (var i = 0; i < x.length || i < y.length; i++) {
final d = (i < x.length ? x[i] : 0) - (i < y.length ? y[i] : 0);
if (d != 0) return d;
}
return 0;
}
/// GitHub Release 정보
class ReleaseInfo {
final String version; // 1.0.1
final String tag; // v1.0.1
final String name;
final String notes;
final String pageUrl;
/// Windows zip (없으면 null → 페이지만 안내)
final String? zipUrl;
final String? zipName;
final int zipSize;
/// GitHub 가 계산한 zip 의 SHA256 (소문자 16진수). 없으면 null
final String? sha256;
const ReleaseInfo({
required this.version,
required this.tag,
required this.name,
required this.notes,
required this.pageUrl,
this.zipUrl,
this.zipName,
this.zipSize = 0,
this.sha256,
});
bool isNewerThan(String current) => compareVersions(version, current) > 0;
}
/// GitHub API `releases/latest` 응답 → ReleaseInfo (초안 · 시험판은 null)
ReleaseInfo? parseLatestRelease(Map<String, dynamic> j) {
if (j['draft'] == true || j['prerelease'] == true) return null;
final tag = j['tag_name'] as String?;
if (tag == null) return null;
Map<String, dynamic>? zip;
for (final a in (j['assets'] as List? ?? const [])) {
final m = a as Map<String, dynamic>;
if (RegExp(r'win64\.zip$', caseSensitive: false).hasMatch(m['name'] as String? ?? '')) {
zip = m;
break;
}
}
String? sha = (zip?['digest'] as String?)?.toLowerCase();
if (sha != null && sha.startsWith('sha256:')) {
sha = sha.substring(7);
} else {
// digest 가 없으면 설명에 적어 둔 SHA256 (64자리 16진수) 사용
sha = RegExp(r'\b([0-9a-fA-F]{64})\b').firstMatch(j['body'] as String? ?? '')?.group(1)?.toLowerCase();
}
return ReleaseInfo(
version: parseVersion(tag).join('.'),
tag: tag,
name: j['name'] as String? ?? tag,
notes: j['body'] as String? ?? '',
pageUrl: j['html_url'] as String? ?? 'https://github.com/$updateRepo/releases',
zipUrl: zip?['browser_download_url'] as String?,
zipName: zip?['name'] as String?,
zipSize: (zip?['size'] as num?)?.toInt() ?? 0,
sha256: sha,
);
}
/// 자동 확인은 하루에 한 번
bool updateCheckDue(String lastCheckIso, DateTime now) {
final last = DateTime.tryParse(lastCheckIso);
return last == null || now.difference(last) >= const Duration(hours: 20);
}

View File

@ -64,6 +64,83 @@ YtDlpProgress? parseYtDlpProgress(String line) {
return YtDlpProgress(pct == null ? null : pct / 100, clean(part(1)), clean(part(2))); return YtDlpProgress(pct == null ? null : pct / 100, clean(part(1)), clean(part(2)));
} }
/// YouTube 재생목록 주소인지 (재생목록 ID 반환).
/// 믹스(RD…) · 좋아요(LL) · 나중에 볼 동영상(WL) 은 제외 (끝없는 목록 · 로그인 필요)
String? youtubePlaylistId(String url) {
final m = RegExp(r'[?&]list=([\w-]+)').firstMatch(url);
if (m == null) return null;
final id = m[1]!;
if (id.startsWith('RD') || id == 'LL' || id == 'WL') return null;
return id;
}
/// 폴더 이름으로 쓸 수 없는 글자 제거
String safeFolderName(String s) {
final t = s.replaceAll(RegExp(r'[<>:"/\\|?*\x00-\x1F]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
final cut = t.length > 80 ? t.substring(0, 80).trim() : t;
return cut.replaceAll(RegExp(r'[. ]+$'), '');
}
/// YouTube 받을 형식
enum YtContainer {
mp4('MP4 (동영상)'),
webm('WebM (동영상)'),
mp3('음성만 MP3'),
m4a('음성만 M4A');
final String label;
const YtContainer(this.label);
bool get audioOnly => this == mp3 || this == m4a;
}
/// YouTube 화질 (세로 픽셀 이하 중 가장 좋은 것)
enum YtQuality {
best('최고 화질', null),
p2160('4K (2160p)', 2160),
p1440('1440p', 1440),
p1080('1080p', 1080),
p720('720p', 720),
p480('480p', 480),
p360('360p', 360);
final String label;
final int? height;
const YtQuality(this.label, this.height);
}
/// yt-dlp 형식 인수
List<String> ytDlpFormatArgs(YtContainer c, YtQuality q) {
final res = q.height == null ? '' : 'res:${q.height},';
return switch (c) {
YtContainer.mp4 => ['-S', '${res}ext:mp4:m4a', '--merge-output-format', 'mp4', '--remux-video', 'mp4'],
YtContainer.webm => ['-S', '${res}ext:webm:webm', '--merge-output-format', 'webm'],
YtContainer.mp3 => ['-f', 'ba/b', '-x', '--audio-format', 'mp3', '--audio-quality', '0'],
YtContainer.m4a => ['-f', 'ba[ext=m4a]/ba/b', '-x', '--audio-format', 'm4a'],
};
}
/// yt-dlp 쿠키 인수 (YouTube 로봇 확인 대응). cookies.txt 가 있으면 우선.
List<String> ytDlpCookieArgs({String browser = '', String file = ''}) {
if (file.trim().isNotEmpty) return ['--cookies', file.trim()];
if (browser.trim().isNotEmpty) return ['--cookies-from-browser', browser.trim()];
return const [];
}
/// yt-dlp 오류를 알기 쉬운 안내로
String friendlyYtDlpError(String err) {
if (err.contains('Sign in to confirm') || err.contains('not a bot')) {
return 'YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 '
'브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.';
}
if (err.contains('Private video') || err.contains('members-only')) return '비공개 · 회원 전용 영상입니다.';
if (err.contains('Video unavailable')) return '볼 수 없는 영상입니다 (삭제 · 지역 제한).';
if (err.contains('could not find') && err.contains('cookies')) {
return '브라우저 쿠키를 읽지 못했습니다. 브라우저를 닫고 다시 시도하거나 cookies.txt 를 지정하세요.';
}
return err;
}
/// 바이트 → 읽기 쉬운 크기 /// 바이트 → 읽기 쉬운 크기
String formatBytes(num b) { String formatBytes(num b) {
if (b < 1024) return '${b.round()}B'; if (b < 1024) return '${b.round()}B';

View File

@ -15,6 +15,7 @@ import 'ui/downloads_page.dart';
import 'ui/home_page.dart'; import 'ui/home_page.dart';
import 'ui/player_page.dart'; import 'ui/player_page.dart';
import 'ui/setup_dialog.dart'; import 'ui/setup_dialog.dart';
import 'ui/update_dialog.dart';
import 'ui/theme.dart'; import 'ui/theme.dart';
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
@ -31,7 +32,7 @@ Future<void> main(List<String> args) async {
await controller.init(); await controller.init();
final downloads = DownloadManager( final downloads = DownloadManager(
backends: services.createDownloadBackends?.call() ?? const [], backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [],
settings: () => controller.settings, settings: () => controller.settings,
); );
final navigatorKey = GlobalKey<NavigatorState>(); final navigatorKey = GlobalKey<NavigatorState>();
@ -85,10 +86,15 @@ Future<void> main(List<String> args) async {
} }
}); });
// 처음 화면이 뜬 뒤 필수 프로그램 점검 (없으면 물어보고 설치) controller.confirmQuit = confirmQuit;
WidgetsBinding.instance.addPostFrameCallback((_) {
// 처음 화면이 뜬 뒤: 필수 프로그램 점검 (없으면 물어보고 설치) → 새 버전 확인 (하루 한 번)
WidgetsBinding.instance.addPostFrameCallback((_) async {
final ctx = navigatorKey.currentContext; final ctx = navigatorKey.currentContext;
if (ctx != null) checkRequiredTools(ctx, services.shell); if (ctx == null) return;
await checkRequiredTools(ctx, services.shell);
final ctx2 = navigatorKey.currentContext;
if (ctx2 != null && ctx2.mounted) await checkForUpdate(ctx2, controller);
}); });
runApp(JjCapCutApp( runApp(JjCapCutApp(

View File

@ -7,10 +7,23 @@ import 'package:path/path.dart' as p;
import '../../services/media_player.dart'; import '../../services/media_player.dart';
/// mpv 가 오류 수준으로 남기지만 재생에는 지장이 없는 메시지.
///
/// 예) AV1 하드웨어 디코딩을 지원하지 않는 그래픽카드에서 mpv 는 d3d11va · dxva2 등을 차례로
/// 시도하다 실패하며 "Could not open codec." 을 남긴 뒤 소프트웨어(libdav1d)로 정상 재생한다.
bool isNonFatalMpvMessage(String m) =>
m.contains('Could not open codec') ||
m.contains('hardware decoding') ||
m.contains('Could not create device');
/// mpv(media_kit) 동영상 플레이어. Windows·Android 공용. /// mpv(media_kit) 동영상 플레이어. Windows·Android 공용.
class MediaKitFullPlayer implements MediaPlayer { class MediaKitFullPlayer implements MediaPlayer {
final mk.Player _p = mk.Player(); final mk.Player _p = mk.Player();
late final VideoController _video = VideoController(_p); late final VideoController _video = VideoController(_p,
configuration: VideoControllerConfiguration(enableHardwareAcceleration: hardwareAcceleration));
/// 하드웨어 디코딩 사용 (지원하지 않는 코덱은 mpv 가 소프트웨어로 대체)
final bool hardwareAcceleration;
final _subs = <StreamSubscription<Object?>>[]; final _subs = <StreamSubscription<Object?>>[];
final _errors = StreamController<String>.broadcast(); final _errors = StreamController<String>.broadcast();
List<String> _files = []; List<String> _files = [];
@ -21,7 +34,7 @@ class MediaKitFullPlayer implements MediaPlayer {
@override @override
final ValueNotifier<PlayerState> state = ValueNotifier(const PlayerState()); final ValueNotifier<PlayerState> state = ValueNotifier(const PlayerState());
MediaKitFullPlayer() { MediaKitFullPlayer({this.hardwareAcceleration = true}) {
final s = _p.stream; final s = _p.stream;
_subs _subs
..add(s.position.listen((_) => _emit())) ..add(s.position.listen((_) => _emit()))
@ -38,7 +51,7 @@ class MediaKitFullPlayer implements MediaPlayer {
_externalSub = null; // 다음 파일로 넘어가면 외부 자막은 풀림 _externalSub = null; // 다음 파일로 넘어가면 외부 자막은 풀림
_emit(); _emit();
})) }))
..add(s.error.listen(_errors.add)); ..add(s.error.where((m) => !isNonFatalMpvMessage(m)).listen(_errors.add));
} }
static String _label(String? title, String? lang, String id) { static String _label(String? title, String? lang, String id) {

View File

@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import '../../services/preview_player.dart'; import '../../services/preview_player.dart';
import 'media_kit_full_player.dart' show isNonFatalMpvMessage;
/// media_kit(libmpv) 기반 재생기. Windows·Android 공용. /// media_kit(libmpv) 기반 재생기. Windows·Android 공용.
class MediaKitPreviewPlayer implements PreviewPlayer { class MediaKitPreviewPlayer implements PreviewPlayer {
@ -31,7 +32,7 @@ class MediaKitPreviewPlayer implements PreviewPlayer {
@override @override
Stream<bool> get playingStream => _player.stream.playing; Stream<bool> get playingStream => _player.stream.playing;
@override @override
Stream<String> get errorStream => _player.stream.error; Stream<String> get errorStream => _player.stream.error.where((m) => !isNonFatalMpvMessage(m));
@override @override
Duration get position => _player.state.position; Duration get position => _player.state.position;

View File

@ -27,6 +27,9 @@ class Aria2Backend implements DownloadBackend {
@override @override
DownloadKind get kind => DownloadKind.torrent; DownloadKind get kind => DownloadKind.torrent;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
static String _randomToken() { static String _randomToken() {
final r = math.Random.secure(); final r = math.Random.secure();
return List.generate(24, (_) => r.nextInt(36).toRadixString(36)).join(); return List.generate(24, (_) => r.nextInt(36).toRadixString(36)).join();

View File

@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import '../../core/app_update.dart';
import '../../services/updater.dart';
/// GitHub Release 로 업데이트 (Windows)
///
/// 1. releases/latest 조회 → 2. zip 받기 · SHA256 확인 · 압축 풀기
/// 3. 업데이트 스크립트 예약: 앱이 끝나면 파일 교체 (받은 파일 · 모델 · 설정은 그대로) → 새 버전 실행
class GitHubUpdater implements Updater {
final String apiBase;
final String appDir;
final String? versionOverride;
final HttpClient _http = HttpClient()
..userAgent = 'JJ_MKVMaker-updater'
..connectionTimeout = const Duration(seconds: 20);
GitHubUpdater({
this.apiBase = 'https://api.github.com',
String? appDir,
this.versionOverride,
}) : appDir = appDir ?? p.dirname(Platform.resolvedExecutable);
@override
Future<String> currentVersion() async =>
versionOverride ?? (await PackageInfo.fromPlatform()).version;
@override
Future<ReleaseInfo?> latest() async {
final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases/latest'));
req.headers.set('Accept', 'application/vnd.github+json');
final res = await req.close();
final body = await res.transform(utf8.decoder).join();
if (res.statusCode == 404) return null;
if (res.statusCode != 200) {
throw UpdateException('최신 버전을 확인할 수 없습니다 (${res.statusCode})');
}
return parseLatestRelease(jsonDecode(body) as Map<String, dynamic>);
}
@override
Future<bool> canInstall() async {
try {
final probe = File(p.join(appDir, '.jj_update_test'));
await probe.writeAsString('x');
await probe.delete();
return true;
} catch (_) {
return false;
}
}
@override
Future<String> download(ReleaseInfo r, void Function(double progress) onProgress) async {
final url = r.zipUrl;
if (url == null) throw const UpdateException('이 버전에는 Windows 용 zip 이 없습니다.');
final work = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update_${r.version}'));
if (await work.exists()) await work.delete(recursive: true);
await work.create(recursive: true);
final zip = File(p.join(work.path, r.zipName ?? 'update.zip'));
// 받기
final req = await _http.getUrl(Uri.parse(url));
final res = await req.close();
if (res.statusCode != 200) throw UpdateException('내려받기 실패 (${res.statusCode})');
final total = res.contentLength > 0 ? res.contentLength : r.zipSize;
final sink = zip.openWrite();
var got = 0;
await for (final chunk in res) {
sink.add(chunk);
got += chunk.length;
if (total > 0) onProgress((got / total).clamp(0.0, 0.99));
}
await sink.close();
// 검증: GitHub 가 알려 준 SHA256 과 비교
if (r.sha256 != null) {
final hash = (await sha256.bind(zip.openRead()).first).toString();
if (hash != r.sha256) {
await work.delete(recursive: true);
throw UpdateException('받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.');
}
}
// 압축 풀기 (Windows 내장 tar)
final out = Directory(p.join(work.path, 'extract'));
await out.create();
final t = await Process.run('tar', ['-xf', zip.path, '-C', out.path]);
if (t.exitCode != 0) throw UpdateException('압축을 풀 수 없습니다: ${t.stderr}');
await zip.delete();
final exeName = p.basename(Platform.resolvedExecutable).toLowerCase();
final exe = await out
.list(recursive: true)
.firstWhere((e) => e is File && p.basename(e.path).toLowerCase() == 'jj_mkvmaker.exe' ||
e is File && p.basename(e.path).toLowerCase() == exeName,
orElse: () => throw const UpdateException('압축 파일 안에 프로그램이 없습니다.'));
onProgress(1);
return p.dirname(exe.path);
}
/// 파일 교체 스크립트 (영문만: Windows PowerShell 5.1 이 BOM 없는 UTF-8 을 잘못 읽는 문제 방지)
static const script = r'''
param([int]$ParentPid, [string]$Source, [string]$Target, [string]$Exe, [switch]$NoRestart)
$ErrorActionPreference = 'Continue'
try { Wait-Process -Id $ParentPid -Timeout 90 -ErrorAction Stop } catch {}
Start-Sleep -Milliseconds 800
$log = Join-Path $env:TEMP 'jj_mkvmaker_update.log'
# keep downloads, models and user data
robocopy $Source $Target /E /R:10 /W:1 /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NP /LOG:$log | Out-Null
if ($LASTEXITCODE -ge 8) { Start-Process notepad.exe $log; exit 1 }
if (-not $NoRestart) { Start-Process -FilePath $Exe }
exit 0
''';
/// 스크립트 실행. [waitPid] 가 끝나기를 기다렸다가 교체한다.
Future<Process> runInstallScript(String source, {required int waitPid, bool restart = true}) async {
final ps1 = File(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update.ps1'));
await ps1.writeAsString(script);
return Process.start(
'powershell.exe',
[
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1.path,
'-ParentPid', '$waitPid', '-Source', source, '-Target', appDir,
'-Exe', p.join(appDir, p.basename(Platform.resolvedExecutable)),
if (!restart) '-NoRestart',
],
mode: restart ? ProcessStartMode.detached : ProcessStartMode.normal,
);
}
@override
Future<void> scheduleInstall(String extractedDir) async {
await runInstallScript(extractedDir, waitPid: pid);
}
@override
Future<void> openPage(ReleaseInfo r) =>
Process.start('explorer.exe', [r.pageUrl], mode: ProcessStartMode.detached);
}

View File

@ -11,8 +11,11 @@ import '../../services/media_tool.dart';
class ProcessMediaTool implements MediaTool { class ProcessMediaTool implements MediaTool {
final String ffmpeg; final String ffmpeg;
final String ffprobe; final String ffprobe;
Process? _running; /// 실행 중인 ffmpeg (여러 개 동시 실행 가능)
bool _cancelled = false; final Set<Process> _running = {};
/// 취소로 종료시킨 프로세스
final Set<Process> _killed = {};
ProcessMediaTool(this.ffmpeg, this.ffprobe); ProcessMediaTool(this.ffmpeg, this.ffprobe);
@ -105,14 +108,13 @@ class ProcessMediaTool implements MediaTool {
Duration? duration, Duration? duration,
ProgressCallback? onProgress, ProgressCallback? onProgress,
}) async { }) async {
_cancelled = false;
final Process proc; final Process proc;
try { try {
proc = await Process.start(ffmpeg, args); proc = await Process.start(ffmpeg, args);
} on ProcessException catch (e) { } on ProcessException catch (e) {
throw MediaToolException('ffmpeg 를 실행할 수 없습니다: ${e.message}'); throw MediaToolException('ffmpeg 를 실행할 수 없습니다: ${e.message}');
} }
_running = proc; _running.add(proc);
final totalUs = duration?.inMicroseconds ?? 0; final totalUs = duration?.inMicroseconds ?? 0;
final stdoutDone = proc.stdout final stdoutDone = proc.stdout
@ -138,22 +140,23 @@ class ProcessMediaTool implements MediaTool {
final code = await proc.exitCode; final code = await proc.exitCode;
await Future.wait([stdoutDone, stderrDone]); await Future.wait([stdoutDone, stderrDone]);
_running = null; _running.remove(proc);
if (_cancelled) throw const MediaToolException('사용자가 취소했습니다.'); if (_killed.remove(proc)) throw const MediaToolException('사용자가 취소했습니다.');
if (code != 0) { if (code != 0) {
throw MediaToolException('ffmpeg 오류 (코드 $code)\n${errTail.join('\n')}'); throw MediaToolException('ffmpeg 오류 (코드 $code)\n${errTail.join('\n')}');
} }
onProgress?.call(1.0); onProgress?.call(1.0);
} }
/// 실행 중인 ffmpeg 모두 중단
@override @override
void cancel() { void cancel() {
final proc = _running; for (final proc in _running.toList()) {
if (proc == null) return; _killed.add(proc);
_cancelled = true; // 'q' 로 정상 종료를 요청하지 않고 즉시 중단 (-nostdin 사용 중)
// 'q' 로 정상 종료를 요청하지 않고 즉시 중단 (-nostdin 사용 중) proc.kill();
proc.kill(); }
} }
} }

View File

@ -33,9 +33,58 @@ class YtDlpBackend implements DownloadBackend {
final _procs = <String, Process>{}; final _procs = <String, Process>{};
final _stopping = <String>{}; final _stopping = <String>{};
YtDlpBackend({String? ytdlp, this.ffmpegDir, String? deno}) /// 형식 · 화질 인수 (환경 설정에서 읽음). 없으면 MP4 최고 화질.
: ytdlp = ytdlp ?? locateTool('yt-dlp'), final List<String> Function() formatArgs;
deno = deno ?? _existing(locateTool('deno'));
/// 쿠키 인수 (YouTube 로봇 확인 대응, 환경 설정에서 읽음)
final List<String> Function() cookieArgs;
YtDlpBackend({
String? ytdlp,
this.ffmpegDir,
String? deno,
List<String> Function()? formatArgs,
List<String> Function()? cookieArgs,
}) : ytdlp = ytdlp ?? locateTool('yt-dlp'),
deno = deno ?? _existing(locateTool('deno')),
formatArgs = formatArgs ?? (() => ytDlpFormatArgs(YtContainer.mp4, YtQuality.best)),
cookieArgs = cookieArgs ?? (() => const []);
static const _env = {'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'};
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async {
final listId = youtubePlaylistId(url);
if (listId == null) return null;
// 영상은 받지 않고 목록만 빠르게 읽는다
final r = await Process.run(
ytdlp,
[
'--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8',
if (deno != null) ...['--js-runtimes', 'deno:$deno'],
...cookieArgs(),
'https://www.youtube.com/playlist?list=$listId',
],
environment: _env,
stdoutEncoding: utf8,
stderrEncoding: utf8,
);
if (r.exitCode != 0) {
throw ProcessException(
ytdlp, [url], '재생목록을 읽을 수 없습니다: ${friendlyYtDlpError((r.stderr as String).trim())}', r.exitCode);
}
final j = jsonDecode(r.stdout as String) as Map<String, dynamic>;
final entries = <PlaylistEntry>[];
for (final e in (j['entries'] as List? ?? const [])) {
final m = e as Map;
final id = m['id'] as String?;
final title = (m['title'] as String?) ?? id ?? '';
// 삭제 · 비공개 영상은 건너뜀
if (id == null || title == '[Deleted video]' || title == '[Private video]') continue;
entries.add(PlaylistEntry('https://www.youtube.com/watch?v=$id', title));
}
return ((j['title'] as String?) ?? listId, entries);
}
static String? _existing(String path) => File(path).existsSync() ? path : null; static String? _existing(String path) => File(path).existsSync() ? path : null;
@ -51,6 +100,8 @@ class YtDlpBackend implements DownloadBackend {
'--print', 'after_move:[JJF]%(filepath)s', '--print', 'after_move:[JJF]%(filepath)s',
'-P', t.dir, '-P', t.dir,
'-o', '%(title).150B [%(id)s].%(ext)s', '-o', '%(title).150B [%(id)s].%(ext)s',
...formatArgs(),
...cookieArgs(),
if (ffmpegDir != null) ...['--ffmpeg-location', ffmpegDir!], if (ffmpegDir != null) ...['--ffmpeg-location', ffmpegDir!],
if (deno != null) ...['--js-runtimes', 'deno:$deno'], if (deno != null) ...['--js-runtimes', 'deno:$deno'],
t.source, t.source,
@ -67,8 +118,7 @@ class YtDlpBackend implements DownloadBackend {
final Process proc; final Process proc;
try { try {
proc = await Process.start(ytdlp, _args(t), proc = await Process.start(ytdlp, _args(t), environment: _env);
environment: {'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'});
} on ProcessException catch (e) { } on ProcessException catch (e) {
t t
..state = DownloadState.failed ..state = DownloadState.failed
@ -124,7 +174,8 @@ class YtDlpBackend implements DownloadBackend {
} else { } else {
t t
..state = DownloadState.failed ..state = DownloadState.failed
..error = errTail.where((l) => l.contains('ERROR')).join('\n').ifEmpty(errTail.join('\n')); ..error = friendlyYtDlpError(
errTail.where((l) => l.contains('ERROR')).join('\n').ifEmpty(errTail.join('\n')));
} }
changed(); changed();
} }

View File

@ -30,14 +30,27 @@ class DownloadTask {
bool get unfinished => bool get unfinished =>
state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused; state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused;
/// 재생목록을 불러오는 중인 임시 항목 (다 불러오면 영상별 항목으로 바뀜)
bool expanding = false;
} }
/// 다운로드 엔진 경계. /// 다운로드 엔진 경계.
/// Windows: yt-dlp.exe 프로세스 / aria2c.exe RPC (platform/windows) /// Windows: yt-dlp.exe 프로세스 / aria2c.exe RPC (platform/windows)
/// Android: (이식 시) youtubedl-android · aria2 안드로이드 빌드 /// Android: (이식 시) youtubedl-android · aria2 안드로이드 빌드
/// 재생목록 안의 항목
class PlaylistEntry {
final String url;
final String title;
const PlaylistEntry(this.url, this.title);
}
abstract class DownloadBackend { abstract class DownloadBackend {
DownloadKind get kind; DownloadKind get kind;
/// 재생목록이면 (목록 이름, 항목들). 재생목록이 아니거나 지원하지 않으면 null.
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url);
/// 시작 (또는 일시정지 후 다시 시작). 상태가 바뀔 때마다 [changed] 호출. /// 시작 (또는 일시정지 후 다시 시작). 상태가 바뀔 때마다 [changed] 호출.
Future<void> start(DownloadTask t, void Function() changed); Future<void> start(DownloadTask t, void Function() changed);

View File

@ -5,12 +5,14 @@ import 'package:path/path.dart' as p;
import '../platform/common/media_kit_full_player.dart'; import '../platform/common/media_kit_full_player.dart';
import '../platform/common/media_kit_player.dart'; import '../platform/common/media_kit_player.dart';
import '../app/settings.dart'; import '../app/settings.dart';
import '../core/download_detect.dart';
import '../platform/common/nllb_translator.dart'; import '../platform/common/nllb_translator.dart';
import '../platform/common/opensubtitles_provider.dart'; import '../platform/common/opensubtitles_provider.dart';
import '../platform/common/whisper_recognizer.dart'; import '../platform/common/whisper_recognizer.dart';
import '../platform/windows/aria2_backend.dart'; import '../platform/windows/aria2_backend.dart';
import '../platform/windows/desktop_shell.dart'; import '../platform/windows/desktop_shell.dart';
import '../platform/windows/desktop_storage_service.dart'; import '../platform/windows/desktop_storage_service.dart';
import '../platform/windows/github_updater.dart';
import '../platform/windows/process_media_tool.dart'; import '../platform/windows/process_media_tool.dart';
import '../platform/windows/ytdlp_backend.dart'; import '../platform/windows/ytdlp_backend.dart';
import 'ai_services.dart'; import 'ai_services.dart';
@ -22,6 +24,7 @@ import 'model_store.dart';
import 'preview_player.dart'; import 'preview_player.dart';
import 'storage_service.dart'; import 'storage_service.dart';
import 'subtitle_provider.dart'; import 'subtitle_provider.dart';
import 'updater.dart';
/// 플랫폼별 구현을 한 곳에서 고른다. /// 플랫폼별 구현을 한 곳에서 고른다.
/// Android 이식 시 이 파일에 분기만 추가하면 된다. /// Android 이식 시 이 파일에 분기만 추가하면 된다.
@ -40,12 +43,15 @@ class PlatformServices {
final Translator Function()? createTranslator; final Translator Function()? createTranslator;
final ModelStore models; final ModelStore models;
/// 다운로드 엔진 (yt-dlp, aria2) /// 다운로드 엔진 (yt-dlp, aria2). 설정값(형식 · 화질)을 읽는 함수를 받는다.
final List<DownloadBackend> Function()? createDownloadBackends; final List<DownloadBackend> Function(AppSettings Function() settings)? createDownloadBackends;
/// 트레이 · 전역 단축키 · 창 닫기 /// 트레이 · 전역 단축키 · 창 닫기
final AppShell shell; final AppShell shell;
/// 새 버전 확인 · 설치 (없으면 업데이트 기능 숨김)
final Updater? updater;
/// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다) /// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다)
final List<SubtitleProvider> Function(AppSettings Function() settings)? createSubtitleProviders; final List<SubtitleProvider> Function(AppSettings Function() settings)? createSubtitleProviders;
@ -58,6 +64,7 @@ class PlatformServices {
this.createTranslator, this.createTranslator,
this.createDownloadBackends, this.createDownloadBackends,
this.createSubtitleProviders, this.createSubtitleProviders,
this.updater,
ModelStore? models, ModelStore? models,
AppShell? shell, AppShell? shell,
}) : models = models ?? ModelStore(), }) : models = models ?? ModelStore(),
@ -75,11 +82,16 @@ class PlatformServices {
createMediaPlayer: MediaKitFullPlayer.new, createMediaPlayer: MediaKitFullPlayer.new,
createRecognizer: WhisperRecognizer.new, createRecognizer: WhisperRecognizer.new,
createTranslator: NllbTranslator.new, createTranslator: NllbTranslator.new,
createDownloadBackends: () => [ createDownloadBackends: (s) => [
YtDlpBackend(ffmpegDir: ffmpegDir), YtDlpBackend(
ffmpegDir: ffmpegDir,
formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality),
cookieArgs: () => ytDlpCookieArgs(browser: s().ytCookiesBrowser, file: s().ytCookiesFile),
),
Aria2Backend(), Aria2Backend(),
], ],
shell: DesktopShell(), shell: DesktopShell(),
updater: GitHubUpdater(),
createSubtitleProviders: (s) => [ createSubtitleProviders: (s) => [
OpenSubtitlesProvider( OpenSubtitlesProvider(
apiKey: () => s().openSubtitlesKey, apiKey: () => s().openSubtitlesKey,

31
lib/services/updater.dart Normal file
View File

@ -0,0 +1,31 @@
import '../core/app_update.dart';
class UpdateException implements Exception {
final String message;
const UpdateException(this.message);
@override
String toString() => message;
}
/// 새 버전 확인 · 설치 경계
/// Windows: platform/windows/github_updater.dart (zip 받아 파일 교체 후 다시 시작)
/// Android: (이식 시) 스토어 / APK 안내
abstract class Updater {
/// 지금 실행 중인 버전 (예: 1.0.1)
Future<String> currentVersion();
/// GitHub 최신 Release (없거나 읽을 수 없으면 null)
Future<ReleaseInfo?> latest();
/// 프로그램 폴더에 쓸 수 있어 자동 설치가 가능한지 (아니면 페이지 안내)
Future<bool> canInstall();
/// zip 을 받아 SHA256 확인 후 압축을 푼 폴더 경로
Future<String> download(ReleaseInfo r, void Function(double progress) onProgress);
/// 앱이 끝나면 파일을 교체하고 새 버전을 실행하도록 예약 (호출 뒤 앱을 종료해야 함)
Future<void> scheduleInstall(String extractedDir);
/// 브라우저로 Release 페이지 열기
Future<void> openPage(ReleaseInfo r);
}

View File

@ -161,7 +161,6 @@ class _DownloadRow extends StatelessWidget {
DownloadState.failed => ('실패', JjColors.danger), DownloadState.failed => ('실패', JjColors.danger),
DownloadState.cancelled => ('취소됨', JjColors.textDim), DownloadState.cancelled => ('취소됨', JjColors.textDim),
}; };
final pct = t.progress == null ? '' : '${(t.progress! * 100).toStringAsFixed(1)}%';
return InkWell( return InkWell(
onTap: () => d.toggle(t), onTap: () => d.toggle(t),
child: Container( child: Container(
@ -180,13 +179,11 @@ class _DownloadRow extends StatelessWidget {
Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis, Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13)), style: const TextStyle(fontSize: 13)),
const SizedBox(height: 4), const SizedBox(height: 4),
if (t.state == DownloadState.downloading || t.state == DownloadState.paused) if (t.state != DownloadState.cancelled) DownloadProgressBar(t: t),
LinearProgressIndicator(value: t.progress, minHeight: 3), const SizedBox(height: 4),
const SizedBox(height: 3),
Text( Text(
[ [
label, label,
if (pct.isNotEmpty) pct,
if (t.speed.isNotEmpty) t.speed, if (t.speed.isNotEmpty) t.speed,
if (t.eta.isNotEmpty) '남은 시간 ${t.eta}', if (t.eta.isNotEmpty) '남은 시간 ${t.eta}',
if (t.error != null) t.error!.split('\n').first, if (t.error != null) t.error!.split('\n').first,
@ -210,6 +207,56 @@ class _DownloadRow extends StatelessWidget {
} }
} }
/// 퍼센트가 보이는 진행 막대
class DownloadProgressBar extends StatelessWidget {
final DownloadTask t;
final double height;
const DownloadProgressBar({super.key, required this.t, this.height = 18});
@override
Widget build(BuildContext context) {
final done = t.state == DownloadState.done;
final value = done ? 1.0 : t.progress;
final color = switch (t.state) {
DownloadState.done => JjColors.success,
DownloadState.failed => JjColors.danger,
DownloadState.paused => Colors.amber,
DownloadState.cancelled => JjColors.textDim,
_ => JjColors.accent,
};
final text = value == null
? (t.state == DownloadState.queued ? '대기' : '준비 중…')
: '${(value * 100).toStringAsFixed(1)}%';
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
height: height,
child: Stack(fit: StackFit.expand, children: [
LinearProgressIndicator(
// 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대
value: value ?? (t.state == DownloadState.downloading ? null : 0),
minHeight: height,
backgroundColor: JjColors.panelHigh,
color: color.withValues(alpha: 0.85),
),
Center(
child: Text(
text,
style: TextStyle(
fontSize: height * 0.62,
height: 1,
fontWeight: FontWeight.w600,
color: Colors.white,
shadows: const [Shadow(blurRadius: 2, color: Colors.black)],
),
),
),
]),
),
);
}
}
/// 종료 확인. 받는 중인 다운로드가 있으면 목록을 보여 주고 한 번 더 확인한다. /// 종료 확인. 받는 중인 다운로드가 있으면 목록을 보여 주고 한 번 더 확인한다.
Future<bool> confirmExit(BuildContext context, DownloadManager d) async { Future<bool> confirmExit(BuildContext context, DownloadManager d) async {
if (d.activeCount == 0) return true; if (d.activeCount == 0) return true;
@ -269,9 +316,10 @@ class _ExitDialog extends StatelessWidget {
? Icons.smart_display_outlined ? Icons.smart_display_outlined
: Icons.cloud_download_outlined), : Icons.cloud_download_outlined),
title: Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis), title: Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: t.progress == null subtitle: Padding(
? null padding: const EdgeInsets.only(top: 4),
: LinearProgressIndicator(value: t.progress, minHeight: 3), child: DownloadProgressBar(t: t, height: 14),
),
trailing: IconButton( trailing: IconButton(
tooltip: '삭제 (중지하고 받던 파일 삭제)', tooltip: '삭제 (중지하고 받던 파일 삭제)',
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),

View File

@ -71,7 +71,19 @@ class HomePage extends StatelessWidget {
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
SizedBox(height: 140, child: _LogPanel(logs: c.logs)), if (c.settings.showLog)
SizedBox(
height: 140,
child: _LogPanel(
logs: c.logs,
onClose: () => c.updateSettings((s) => s.showLog = false),
),
)
else
_LogStrip(
last: c.logs.isEmpty ? '' : c.logs.last,
onOpen: () => c.updateSettings((s) => s.showLog = true),
),
], ],
), ),
), ),
@ -185,12 +197,19 @@ class _DownloadBox extends StatelessWidget {
Icon(Icons.downloading, size: 16, Icon(Icons.downloading, size: 16,
color: n > 0 ? JjColors.accent : JjColors.textDim), color: n > 0 ? JjColors.accent : JjColors.textDim),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(n > 0 ? '다운로딩 $n' : '다운로드 ${d.tasks.length}', Text(
n > 0
? '다운로딩 $n${p == null ? '' : ' · ${(p * 100).toStringAsFixed(0)}%'}'
: '다운로드 ${d.tasks.length}',
style: const TextStyle(fontSize: 12)), style: const TextStyle(fontSize: 12)),
]), ]),
if (n > 0) ...[ if (n > 0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
LinearProgressIndicator(value: p, minHeight: 2), ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: p, minHeight: 4, backgroundColor: JjColors.panelHigh),
),
], ],
], ],
), ),
@ -660,19 +679,66 @@ class _CharsetDropdown extends StatelessWidget {
class _LogPanel extends StatelessWidget { class _LogPanel extends StatelessWidget {
final List<String> logs; final List<String> logs;
const _LogPanel({required this.logs}); final VoidCallback onClose;
const _LogPanel({required this.logs, required this.onClose});
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => Container(
color: JjColors.bg, color: JjColors.bg,
child: ListView.builder( child: Stack(children: [
reverse: true, Positioned.fill(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListView.builder(
itemCount: logs.length, reverse: true,
itemBuilder: (_, i) => SelectableText( padding: const EdgeInsets.fromLTRB(16, 8, 44, 8),
logs[logs.length - 1 - i], itemCount: logs.length,
style: const TextStyle( itemBuilder: (_, i) => SelectableText(
fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim), logs[logs.length - 1 - i],
style: const TextStyle(
fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim),
),
),
),
// 오른쪽 위 ✕: 작업 기록 숨기기
Positioned(
top: 2,
right: 4,
child: IconButton(
tooltip: '작업 기록 숨기기',
iconSize: 16,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: JjColors.textDim),
onPressed: onClose,
),
),
]),
);
}
/// 작업 기록을 숨겼을 때 맨 아래 얇은 줄: 마지막 기록 + 다시 보기
class _LogStrip extends StatelessWidget {
final String last;
final VoidCallback onOpen;
const _LogStrip({required this.last, required this.onOpen});
@override
Widget build(BuildContext context) => Material(
color: JjColors.bg,
child: InkWell(
onTap: onOpen,
child: SizedBox(
height: 26,
child: Row(children: [
const SizedBox(width: 16),
Expanded(
child: Text(last,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)),
),
const Text('작업 기록 보기', style: TextStyle(fontSize: 11, color: JjColors.accent)),
const Icon(Icons.expand_less, size: 16, color: JjColors.accent),
const SizedBox(width: 12),
]),
), ),
), ),
); );

View File

@ -63,7 +63,14 @@ class _PlayerPageState extends State<PlayerPage> {
super.initState(); super.initState();
pl.open(widget.files, start: widget.start); pl.open(widget.files, start: widget.start);
pl.state.addListener(_onState); pl.state.addListener(_onState);
_errSub = pl.errors.listen((e) => setState(() => _error = e)); // 오류는 3초 뒤에도 실제로 재생이 안 될 때만 표시 (mpv 는 대체 방법으로 계속 재생하는 경우가 많음)
_errSub = pl.errors.listen((e) {
Future<void>.delayed(const Duration(seconds: 3), () {
final s = pl.state.value;
final stuck = s.position < const Duration(milliseconds: 500) && s.duration == Duration.zero;
if (mounted && stuck) setState(() => _error = e);
});
});
} }
@override @override

View File

@ -3,13 +3,87 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/download_detect.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
import '../core/subtitle_detector.dart'; import '../core/subtitle_detector.dart';
import '../services/app_shell.dart'; import '../services/app_shell.dart';
import 'setup_dialog.dart'; import 'setup_dialog.dart';
import 'update_dialog.dart';
import 'theme.dart'; import 'theme.dart';
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
class CountSelector extends StatelessWidget {
final int value;
final ValueChanged<int> onChanged;
const CountSelector({super.key, required this.value, required this.onChanged});
static const _presets = [1, 5, 10, 0];
static String label(int n) => n <= 0 ? '무한' : '$n개';
Future<void> _custom(BuildContext context) async {
// 목록 메뉴가 완전히 닫힌 뒤 입력 창을 연다
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
final n = await showDialog<int>(
context: context,
builder: (_) => _CountInputDialog(initial: value),
);
if (n != null && n >= 0) onChanged(n);
}
@override
Widget build(BuildContext context) {
final items = {..._presets, value};
return DropdownButton<int>(
value: value,
items: [
for (final n in items) DropdownMenuItem(value: n, child: Text(label(n))),
const DropdownMenuItem(value: -1, child: Text('직접 입력…')),
],
onChanged: (n) => n == -1 ? _custom(context) : onChanged(n!),
);
}
}
/// 개수 직접 입력 창 (입력칸은 창이 닫히는 애니메이션이 끝난 뒤 정리)
class _CountInputDialog extends StatefulWidget {
final int initial;
const _CountInputDialog({required this.initial});
@override
State<_CountInputDialog> createState() => _CountInputDialogState();
}
class _CountInputDialogState extends State<_CountInputDialog> {
late final _ctrl = TextEditingController(text: widget.initial > 0 ? '${widget.initial}' : '');
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('직접 입력'),
content: TextField(
controller: _ctrl,
autofocus: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: '동시에 처리할 개수 (0 = 무한)', border: OutlineInputBorder()),
onSubmitted: (t) => Navigator.pop(context, int.tryParse(t)),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(context, int.tryParse(_ctrl.text)), child: const Text('확인')),
],
);
}
/// 확장자별 재생 프로그램 (내장 / 기본 프로그램 / VLC / 직접 지정) /// 확장자별 재생 프로그램 (내장 / 기본 프로그램 / VLC / 직접 지정)
class _ExternalPlayers extends StatelessWidget { class _ExternalPlayers extends StatelessWidget {
final AppController c; final AppController c;
@ -234,6 +308,84 @@ class _SettingsPageState extends State<SettingsPage> {
(v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), (v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())),
_textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 PC 의 설정 파일에 저장됩니다', _textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 PC 의 설정 파일에 저장됩니다',
(v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true),
ListTile(
title: const Text('YouTube 받을 형식'),
subtitle: const Text('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장'),
trailing: DropdownButton<YtContainer>(
value: s.ytContainer,
items: [for (final v in YtContainer.values) DropdownMenuItem(value: v, child: Text(v.label))],
onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!),
),
),
ListTile(
enabled: !s.ytContainer.audioOnly,
title: const Text('YouTube 화질'),
subtitle: const Text('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)'),
trailing: DropdownButton<YtQuality>(
value: s.ytQuality,
items: [for (final v in YtQuality.values) DropdownMenuItem(value: v, child: Text(v.label))],
onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytQuality = v!),
),
),
SwitchListTile(
value: s.ytExpandPlaylists,
onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v),
title: const Text('재생목록 주소면 목록 전체 받기'),
subtitle: const Text('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)'),
),
ListTile(
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" 오류가 나오면 설정하세요'),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
DropdownButton<String>(
value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser,
items: [
const DropdownMenuItem(value: '', 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 파일…')),
],
onChanged: (v) async {
if (v == '*file') {
final f = await FilePicker.pickFile(
dialogTitle: 'cookies.txt (Netscape 형식)',
type: FileType.custom,
allowedExtensions: const ['txt']);
if (f?.path != null) {
await c.updateSettings((x) => x
..ytCookiesFile = f!.path!
..ytCookiesBrowser = '');
}
} else {
await c.updateSettings((x) => x
..ytCookiesBrowser = v ?? ''
..ytCookiesFile = '');
}
},
),
]),
),
ListTile(
title: const Text('동시 다운로드 수'),
subtitle: const Text('나머지는 대기했다가 차례로 받습니다'),
trailing: CountSelector(
value: s.maxParallelDownloads,
onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n),
),
),
_section('변환'),
ListTile(
title: const Text('동시 MKV 변환 수'),
subtitle: const Text('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)'),
trailing: CountSelector(
value: s.maxParallelJobs,
onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n),
),
),
_section('실행'), _section('실행'),
SwitchListTile( SwitchListTile(
value: s.minimizeToTray, value: s.minimizeToTray,
@ -270,6 +422,24 @@ class _SettingsPageState extends State<SettingsPage> {
'언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'), '언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'),
), ),
_section('프로그램'), _section('프로그램'),
if (c.services.updater != null) ...[
ListTile(
title: const Text('업데이트'),
subtitle: FutureBuilder<String>(
future: c.services.updater!.currentVersion(),
builder: (_, v) => Text('지금 버전: v${v.data ?? '…'} · GitHub 최신 Release 와 비교합니다'),
),
trailing: OutlinedButton(
onPressed: () => checkForUpdate(context, c, manual: true),
child: const Text('지금 확인'),
),
),
SwitchListTile(
value: s.autoCheckUpdates,
onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v),
title: const Text('시작할 때 새 버전 확인 (하루 한 번)'),
),
],
ListTile( ListTile(
title: const Text('필수 프로그램 점검'), title: const Text('필수 프로그램 점검'),
subtitle: const Text('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다'), subtitle: const Text('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다'),

147
lib/ui/update_dialog.dart Normal file
View File

@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../core/app_update.dart';
import 'theme.dart';
/// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작
///
/// [manual]: 환경 설정에서 "지금 확인" 을 누른 경우 (최신이어도 알려 주고, 건너뛴 버전도 보여 줌)
Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual = false}) async {
final up = c.services.updater;
if (up == null) return;
final s = c.settings;
if (!manual && (!s.autoCheckUpdates || !updateCheckDue(s.lastUpdateCheck, DateTime.now()))) return;
void snack(String t) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t)));
}
final String current;
final ReleaseInfo? r;
try {
current = await up.currentVersion();
r = await up.latest();
} catch (e) {
if (manual) snack('새 버전을 확인할 수 없습니다: $e');
return;
}
await c.updateSettings((x) => x.lastUpdateCheck = DateTime.now().toIso8601String());
if (r == null || !r.isNewerThan(current)) {
if (manual) snack('최신 버전입니다 (v$current)');
return;
}
if (!manual && s.skippedVersion == r.version) return;
if (!context.mounted) return;
final choice = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('새 버전 v${r!.version}'),
content: SizedBox(
width: 560,
height: 380,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('지금 쓰는 버전: v$current → 새 버전: v${r.version}'),
if (r.zipSize > 0)
Text('받는 크기: 약 ${(r.zipSize / 1e6).round()}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다',
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 12),
const Text('바뀐 내용', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
child: SingleChildScrollView(
child: SelectableText(r.notes.isEmpty ? '(설명 없음)' : r.notes,
style: const TextStyle(fontSize: 12, height: 1.5)),
),
),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, 'skip'), child: const Text('이 버전 건너뛰기')),
TextButton(onPressed: () => Navigator.pop(ctx, 'page'), child: const Text('페이지 열기')),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('나중에')),
FilledButton.icon(
onPressed: () => Navigator.pop(ctx, 'update'),
icon: const Icon(Icons.system_update_alt, size: 18),
label: const Text('업데이트'),
),
],
),
);
switch (choice) {
case 'skip':
await c.updateSettings((x) => x.skippedVersion = r!.version);
return;
case 'page':
await up.openPage(r);
return;
case 'update':
break;
default:
return;
}
if (c.busy) {
snack('MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.');
return;
}
// 프로그램 폴더에 쓸 수 없으면 (예: Program Files) 페이지에서 직접 받도록
if (r.zipUrl == null || !await up.canInstall()) {
snack('자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.');
await up.openPage(r);
return;
}
if (!context.mounted) return;
final progress = ValueNotifier<double>(0);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text('v${r!.version} 받는 중'),
content: ValueListenableBuilder<double>(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
Text(v >= 1 ? '확인 · 압축 푸는 중…' : '${(v * 100).round()}%'),
const SizedBox(height: 12),
LinearProgressIndicator(value: v >= 1 ? null : v),
]),
),
),
);
String dir;
try {
dir = await up.download(r, (x) => progress.value = x);
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
snack('업데이트 실패: $e');
return;
}
if (!context.mounted) return;
Navigator.of(context).pop();
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('설치 준비 완료'),
content: Text('v${r!.version} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n'
'프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('나중에')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('지금 설치')),
],
),
);
if (go != true) return;
// 다운로드 중이면 기존 종료 확인 절차
final ok = await (c.confirmQuit?.call() ?? Future.value(true));
if (!ok) return;
await up.scheduleInstall(dir);
await c.services.shell.quit();
}

View File

@ -106,7 +106,7 @@ packages:
source: hosted source: hosted
version: "0.3.5+5" version: "0.3.5+5"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@ -544,7 +544,7 @@ packages:
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
package_info_plus: package_info_plus:
dependency: transitive dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.1+2
environment: environment:
sdk: ^3.13.4 sdk: ^3.13.4
@ -52,6 +52,8 @@ dependencies:
hotkey_manager: ^0.2.3 hotkey_manager: ^0.2.3
desktop_drop: ^0.8.0 desktop_drop: ^0.8.0
screen_retriever: ^0.2.2 screen_retriever: ^0.2.2
package_info_plus: ^10.2.1
crypto: ^3.0.7
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View File

@ -18,6 +18,12 @@ class _FakeBackend implements DownloadBackend {
final started = <String>[]; final started = <String>[];
_FakeBackend(this.kind); _FakeBackend(this.kind);
/// 재생목록 흉내 (null 이면 재생목록 아님)
(String, List<PlaylistEntry>)? playlist;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => playlist;
@override @override
Future<void> start(DownloadTask t, void Function() changed) async { Future<void> start(DownloadTask t, void Function() changed) async {
started.add(t.source); started.add(t.source);

View File

@ -11,6 +11,8 @@ class _Backend implements DownloadBackend {
final DownloadKind kind; final DownloadKind kind;
_Backend(this.kind); _Backend(this.kind);
@override @override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async { Future<void> start(DownloadTask t, void Function() changed) async {
t t
..state = DownloadState.downloading ..state = DownloadState.downloading
@ -51,7 +53,10 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('테스트 영상'), findsOneWidget); expect(find.text('테스트 영상'), findsOneWidget);
expect(find.text('리눅스 ISO'), findsOneWidget); expect(find.text('리눅스 ISO'), findsOneWidget);
expect(find.textContaining('받는 중 · 40.0% · 1.2MB/s'), findsNWidgets(2)); // 진행 막대 안에 퍼센트, 아래에 상태 · 속도
expect(find.byType(DownloadProgressBar), findsNWidgets(2));
expect(find.text('40.0%'), findsNWidgets(2));
expect(find.textContaining('받는 중 · 1.2MB/s'), findsNWidgets(2));
await tester.tap(find.text('전체 선택')); await tester.tap(find.text('전체 선택'));
await tester.pump(); await tester.pump();
@ -61,8 +66,12 @@ void main() {
expect(d.tasks.every((t) => t.state == DownloadState.paused), isTrue); expect(d.tasks.every((t) => t.state == DownloadState.paused), isTrue);
d.tasks.first.state = DownloadState.done; d.tasks.first.state = DownloadState.done;
d.tasks.last.progress = null; // 진행률을 모르는 상태 (마그넷 정보 받는 중 등)
d.tasks.last.state = DownloadState.downloading;
d.selectNone(); // 화면 갱신 d.selectNone(); // 화면 갱신
await tester.pump(); await tester.pump();
expect(find.text('100.0%'), findsOneWidget); // 완료는 가득 찬 막대
expect(find.text('준비 중…'), findsOneWidget);
await tester.tap(find.text('완료 정리')); await tester.tap(find.text('완료 정리'));
await tester.pump(); await tester.pump();
expect(d.tasks, hasLength(1)); expect(d.tasks, hasLength(1));

242
test/queue_test.dart Normal file
View File

@ -0,0 +1,242 @@
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/download_manager.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/main.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/services/media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
/// 시작만 하고 끝내기는 테스트가 직접 (finish)
class _Backend implements DownloadBackend {
@override
final DownloadKind kind;
_Backend(this.kind);
final running = <DownloadTask, void Function()>{};
(String, List<PlaylistEntry>)? playlist;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => playlist;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
t.state = DownloadState.downloading;
running[t] = changed;
changed();
}
void finish(DownloadTask t) {
t.state = DownloadState.done;
running.remove(t)!();
}
@override
Future<void> pause(DownloadTask t) async {
running.remove(t);
t.state = DownloadState.paused;
}
@override
Future<void> cancel(DownloadTask t) async {
running.remove(t);
t.state = DownloadState.cancelled;
}
@override
Future<void> shutdown() async {}
}
/// 동시에 몇 개가 실행되는지 재는 가짜 FFmpeg
class _Tool implements MediaTool {
var now = 0, peak = 0;
@override
Future<String?> version() async => 'ffmpeg test';
@override
Future<Set<String>> encoders() async => {'libx264'};
@override
Future<MediaInfo> probe(String path) async => const MediaInfo(duration: Duration(seconds: 1));
@override
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress}) async {
now++;
if (now > peak) peak = now;
await Future<void>.delayed(const Duration(milliseconds: 30));
now--;
}
@override
void cancel() {}
}
void main() {
group('YouTube 주소 · 형식', () {
test('재생목록 판별', () {
expect(youtubePlaylistId('https://www.youtube.com/playlist?list=PLabc_123'), 'PLabc_123');
expect(youtubePlaylistId('https://www.youtube.com/watch?v=x1&list=PLxyz&index=3'), 'PLxyz');
expect(youtubePlaylistId('https://www.youtube.com/watch?v=x1&list=RDx1'), isNull); // 믹스
expect(youtubePlaylistId('https://www.youtube.com/watch?v=x1'), isNull);
});
test('폴더 이름 정리', () {
expect(safeFolderName('My: List / 2024?'), 'My List 2024');
expect(safeFolderName('끝 점...'), '끝 점');
});
test('형식 인수', () {
expect(ytDlpFormatArgs(YtContainer.mp4, YtQuality.p1080).join(' '),
'-S res:1080,ext:mp4:m4a --merge-output-format mp4 --remux-video mp4');
expect(ytDlpFormatArgs(YtContainer.webm, YtQuality.best).join(' '), '-S ext:webm:webm --merge-output-format webm');
expect(ytDlpFormatArgs(YtContainer.mp3, YtQuality.p720), contains('mp3'));
expect(ytDlpCookieArgs(browser: 'firefox'), ['--cookies-from-browser', 'firefox']);
expect(ytDlpCookieArgs(browser: 'firefox', file: r'C:\c.txt'), ['--cookies', r'C:\c.txt']);
expect(ytDlpCookieArgs(), isEmpty);
expect(friendlyYtDlpError('ERROR: [youtube] x: Sign in to confirm you’re not a bot'), contains('로봇 확인'));
});
});
group('다운로드 대기열 · 재생목록', () {
late _Backend video;
late AppSettings settings;
late DownloadManager d;
setUp(() {
video = _Backend(DownloadKind.video);
settings = AppSettings()
..downloadRoot = r'D:\dl'
..maxParallelDownloads = 2;
d = DownloadManager(backends: [video], settings: () => settings, readClipboard: () async => null);
});
tearDown(() => d.dispose());
test('동시 2개, 끝나면 다음 차례 (들어온 순서)', () async {
final ts = [for (var i = 1; i <= 5; i++) d.add('https://youtu.be/v$i')!];
await Future<void>.delayed(Duration.zero);
expect(ts.map((t) => t.state.name), ['downloading', 'downloading', 'queued', 'queued', 'queued']);
video.finish(ts[0]);
await Future<void>.delayed(Duration.zero);
expect(ts[2].state, DownloadState.downloading);
expect(ts[3].state, DownloadState.queued);
// 일시정지하면 자리가 비어 다음이 시작, 재개하면 대기열로
await d.pause([ts[1]]);
expect(ts[3].state, DownloadState.downloading);
await d.resume([ts[1]]);
expect(ts[1].state, DownloadState.queued);
settings.maxParallelDownloads = 0; // 무한
await d.resume(const []);
expect(ts.where((t) => t.state == DownloadState.downloading), hasLength(4));
});
test('재생목록 → 영상별 항목, 하위 폴더, 이미 받는 영상 제외', () async {
d.add('https://youtu.be/b');
video.playlist = ('My: Playlist', const [
PlaylistEntry('https://youtu.be/a', '첫째'),
PlaylistEntry('https://youtu.be/b', '둘째'),
PlaylistEntry('https://youtu.be/c', '셋째'),
]);
final holder = d.add('https://www.youtube.com/playlist?list=PLtest')!;
expect(holder.expanding, isTrue);
expect(holder.title, contains('재생목록 불러오는 중'));
await Future<void>.delayed(const Duration(milliseconds: 10));
final titles = d.tasks.map((t) => t.title).toList();
expect(titles.take(2), ['[1/3] 첫째', '[3/3] 셋째']);
expect(d.tasks.first.dir, p.join(r'D:\dl', 'jj_yt-dlp', 'My Playlist'));
expect(d.tasks.contains(holder), isFalse);
settings.ytExpandPlaylists = false;
final single = d.add('https://www.youtube.com/watch?v=z&list=PLother')!;
expect(single.expanding, isFalse);
});
});
test('MKV 동시 변환 수: 1 · 2 · 무한', () async {
for (final (limit, expected) in [(1, 1), (2, 2), (0, 5)]) {
final tool = _Tool();
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init();
c.settings.maxParallelJobs = limit;
final dir = Directory.systemTemp.createTempSync('jj_par_');
for (var i = 0; i < 5; i++) {
c.videos.add(VideoItem(p.join(dir.path, 'v$i.mp4'))..info = const MediaInfo(duration: Duration(seconds: 1)));
}
await c.buildAll();
dir.deleteSync(recursive: true);
expect(tool.peak, expected, reason: '최대 $limit');
expect(c.videos.every((v) => v.status == JobStatus.done), isTrue);
}
});
test('실제 yt-dlp 재생목록 읽기 (JJ_NET_TESTS=1)', () async {
final exe = p.join(Directory.current.path, 'third_party', 'tools', 'windows', 'yt-dlp.exe');
if (Platform.environment['JJ_NET_TESTS'] != '1' || !File(exe).existsSync()) {
return markTestSkipped('인터넷 테스트 꺼짐');
}
final b = YtDlpBackend(ytdlp: exe, deno: p.join(p.dirname(exe), 'deno.exe'));
final r = await b.expandPlaylist('https://www.youtube.com/watch?v=x&list=PLzH6n4zXuckpfMu_4Ff8E7Z1behQks5ba');
expect(r, isNotNull);
expect(r!.$1, contains('Data Analysis'));
expect(r.$2.length, greaterThanOrEqualTo(10));
expect(r.$2.first.url, startsWith('https://www.youtube.com/watch?v='));
}, timeout: const Timeout(Duration(minutes: 2)));
testWidgets('작업 기록 ✕ → 숨김, 아래 줄 누르면 다시 보임', (tester) async {
tester.view.physicalSize = const Size(1400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService()));
await c.init();
await tester.pumpWidget(JjCapCutApp(controller: c));
expect(find.byTooltip('작업 기록 숨기기'), findsOneWidget);
await tester.tap(find.byTooltip('작업 기록 숨기기'));
await tester.pump();
expect(c.settings.showLog, isFalse);
expect(find.text('작업 기록 보기'), findsOneWidget);
await tester.tap(find.text('작업 기록 보기'));
await tester.pump();
expect(c.settings.showLog, isTrue);
expect(find.byTooltip('작업 기록 숨기기'), findsOneWidget);
});
testWidgets('환경 설정: 새 항목이 깨지지 않고 보임', (tester) async {
// 스크롤 없이 모두 보이도록 세로로 긴 화면
tester.view.physicalSize = const Size(1400, 3200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService()));
await tester.pumpWidget(MaterialApp(home: SettingsPage(c: c)));
await tester.pumpAndSettle();
for (final t in ['YouTube 받을 형식', 'YouTube 화질', '재생목록 주소면 목록 전체 받기', 'YouTube 쿠키 (로봇 확인이 나올 때)',
'동시 다운로드 수', '동시 MKV 변환 수']) {
expect(find.text(t), findsOneWidget, reason: t);
}
expect(find.text('MP4 (동영상)'), findsOneWidget);
expect(find.text('3개'), findsOneWidget); // 동시 다운로드 기본
expect(find.text('5개'), findsOneWidget); // 동시 변환 기본
});
testWidgets('개수 선택: 1 · 5 · 10 · 무한 · 직접 입력', (tester) async {
var value = 5;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (_, set) => CountSelector(value: value, onChanged: (n) => set(() => value = n)),
),
),
));
await tester.tap(find.text('5개'));
await tester.pumpAndSettle();
for (final t in ['1개', '10개', '무한', '직접 입력…']) {
expect(find.text(t), findsWidgets);
}
await tester.tap(find.text('직접 입력…').last);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '7');
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(value, 7);
expect(find.text('7개'), findsOneWidget);
});
}

142
test/update_test.dart Normal file
View File

@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/app_update.dart';
import 'package:jj_mkvmaker/platform/windows/github_updater.dart';
import 'package:path/path.dart' as p;
// 화면 테스트는 update_ui_test.dart (화면 테스트가 같은 파일에 있으면 실제 네트워크 연결이 막힘)
Map<String, dynamic> _release(String tag, {String? digest, String body = '', bool draft = false, String? url}) => {
'tag_name': tag,
'name': 'JJ_MKVMaker $tag',
'body': body,
'draft': draft,
'prerelease': false,
'html_url': 'https://github.com/$updateRepo/releases/tag/$tag',
'assets': [
{
'name': 'JJ_MKVMaker_${tag}_win64.zip',
'size': 1234,
'browser_download_url': url ?? 'https://example.com/x.zip',
'digest': ?digest,
},
],
};
void main() {
group('버전 · Release 해석', () {
test('버전 비교', () {
expect(compareVersions('1.0.1', '1.0.0'), greaterThan(0));
expect(compareVersions('v1.2', '1.2.0'), 0);
expect(compareVersions('1.10.0', '1.9.9'), greaterThan(0));
expect(compareVersions('1.0.0+5', 'v1.0.1'), lessThan(0));
});
test('releases/latest 해석', () {
final r = parseLatestRelease(_release('v1.2.0', digest: 'sha256:${'A' * 64}'))!;
expect([r.version, r.tag, r.zipName, r.sha256], ['1.2.0', 'v1.2.0', 'JJ_MKVMaker_v1.2.0_win64.zip', 'a' * 64]);
expect(r.isNewerThan('1.0.1'), isTrue);
// digest 없으면 설명의 SHA256
final b = parseLatestRelease(_release('v1.2.0', body: '| zip | `${'B' * 64}` |'))!;
expect(b.sha256, 'b' * 64);
expect(parseLatestRelease(_release('v9.0.0', draft: true)), isNull);
});
test('하루 한 번', () {
final now = DateTime(2026, 10, 1, 12);
expect(updateCheckDue('', now), isTrue);
expect(updateCheckDue(DateTime(2026, 10, 1, 9).toIso8601String(), now), isFalse);
expect(updateCheckDue(DateTime(2026, 9, 30, 9).toIso8601String(), now), isTrue);
});
});
group('GitHubUpdater (가짜 GitHub 서버 · 실제 스크립트)', () {
late HttpServer server;
late Directory tmp;
late List<int> zipBytes;
var digest = '';
setUpAll(() async {
tmp = Directory.systemTemp.createTempSync('jj_upd_');
// 새 버전 zip: JJ_MKVMaker\jj_mkvmaker.exe · new.txt · data\app.so
final src = Directory(p.join(tmp.path, 'pkg', 'JJ_MKVMaker'))..createSync(recursive: true);
File(p.join(src.path, 'jj_mkvmaker.exe')).writeAsStringSync('NEW EXE');
File(p.join(src.path, 'new.txt')).writeAsStringSync('new file');
Directory(p.join(src.path, 'data')).createSync();
File(p.join(src.path, 'data', 'app.so')).writeAsStringSync('NEW DATA');
final zip = p.join(tmp.path, 'u.zip');
final r = await Process.run('tar', ['-a', '-cf', zip, '-C', p.join(tmp.path, 'pkg'), 'JJ_MKVMaker']);
expect(r.exitCode, 0, reason: '${r.stderr}');
zipBytes = File(zip).readAsBytesSync();
digest = sha256.convert(zipBytes).toString();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((req) async {
final base = 'http://127.0.0.1:${server.port}';
if (req.uri.path == '/repos/$updateRepo/releases/latest') {
req.response.headers.contentType = ContentType.json;
final bad = req.uri.queryParameters['bad'] == '1';
req.response.write(jsonEncode(_release('v1.9.0',
digest: 'sha256:${bad ? '0' * 64 : digest}', url: '$base/dl/u.zip')));
} else if (req.uri.path == '/dl/u.zip') {
req.response
..contentLength = zipBytes.length
..add(zipBytes);
} else {
req.response.statusCode = 404;
}
await req.response.close();
});
});
tearDownAll(() async {
await server.close(force: true);
tmp.deleteSync(recursive: true);
});
test('최신 확인 → 받기 · SHA256 확인 · 압축 풀기', () async {
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, versionOverride: '1.0.1');
final r = (await up.latest())!;
expect(r.isNewerThan(await up.currentVersion()), isTrue);
final progress = <double>[];
final dir = await up.download(r, progress.add);
expect(File(p.join(dir, 'jj_mkvmaker.exe')).readAsStringSync(), 'NEW EXE');
expect(progress.last, 1);
});
test('SHA256 이 다르면 설치 거부', () async {
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path);
final good = (await up.latest())!;
final bad = ReleaseInfo(
version: good.version, tag: good.tag, name: '', notes: '', pageUrl: '',
zipUrl: good.zipUrl, zipName: good.zipName, sha256: '0' * 64);
await expectLater(up.download(bad, (_) {}), throwsA(predicate((e) => '$e'.contains('SHA256'))));
});
test('업데이트 스크립트: 프로그램 파일만 교체, 받은 파일 · 모델 보존', () async {
if (!Platform.isWindows) return markTestSkipped('Windows 전용');
final app = Directory(p.join(tmp.path, 'installed'))..createSync();
File(p.join(app.path, 'jj_mkvmaker.exe')).writeAsStringSync('OLD EXE');
File(p.join(app.path, 'old_only.txt')).writeAsStringSync('keep me');
Directory(p.join(app.path, 'jj_yt-dlp')).createSync();
File(p.join(app.path, 'jj_yt-dlp', 'video.mp4')).writeAsStringSync('my download');
Directory(p.join(app.path, 'models')).createSync();
File(p.join(app.path, 'models', 'm.bin')).writeAsStringSync('model');
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: app.path);
final dir = await up.download((await up.latest())!, (_) {});
// 이미 끝난 프로세스를 "앱" 으로 두고 실행 (바로 교체 진행)
final dummy = await Process.start('cmd', ['/c', 'exit']);
await dummy.exitCode;
final proc = await up.runInstallScript(dir, waitPid: dummy.pid, restart: false);
expect(await proc.exitCode, 0);
expect(File(p.join(app.path, 'jj_mkvmaker.exe')).readAsStringSync(), 'NEW EXE');
expect(File(p.join(app.path, 'new.txt')).existsSync(), isTrue);
expect(File(p.join(app.path, 'data', 'app.so')).readAsStringSync(), 'NEW DATA');
expect(File(p.join(app.path, 'old_only.txt')).readAsStringSync(), 'keep me');
expect(File(p.join(app.path, 'jj_yt-dlp', 'video.mp4')).readAsStringSync(), 'my download');
expect(File(p.join(app.path, 'models', 'm.bin')).readAsStringSync(), 'model');
}, timeout: const Timeout(Duration(minutes: 2)));
});
}

109
test/update_ui_test.dart Normal file
View File

@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/app_update.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/services/updater.dart';
import 'package:jj_mkvmaker/ui/update_dialog.dart';
Map<String, dynamic> _release(String tag, {String? digest, String body = '', bool draft = false, String? url}) => {
'tag_name': tag,
'name': 'JJ_MKVMaker $tag',
'body': body,
'draft': draft,
'prerelease': false,
'html_url': 'https://github.com/$updateRepo/releases/tag/$tag',
'assets': [
{
'name': 'JJ_MKVMaker_${tag}_win64.zip',
'size': 1234,
'browser_download_url': url ?? 'https://example.com/x.zip',
'digest': ?digest,
},
],
};
class _FakeUpdater implements Updater {
ReleaseInfo? next;
final calls = <String>[];
@override
Future<String> currentVersion() async => '1.0.1';
@override
Future<ReleaseInfo?> latest() async => next;
@override
Future<bool> canInstall() async => true;
@override
Future<String> download(ReleaseInfo r, void Function(double) onProgress) async {
calls.add('download ${r.version}');
onProgress(1);
return r'C:\tmp\new';
}
@override
Future<void> scheduleInstall(String dir) async => calls.add('install $dir');
@override
Future<void> openPage(ReleaseInfo r) async => calls.add('page');
}
void main() {
group('업데이트 창', () {
late _FakeUpdater up;
late AppController c;
setUp(() {
up = _FakeUpdater();
c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up));
});
Future<void> open(WidgetTester tester, {bool manual = true}) async {
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Builder(
builder: (ctx) => TextButton(
onPressed: () => checkForUpdate(ctx, c, manual: manual),
child: const Text('확인'),
),
),
),
));
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
}
testWidgets('최신이면 알림만', (tester) async {
up.next = parseLatestRelease(_release('v1.0.1'));
await open(tester);
expect(find.text('최신 버전입니다 (v1.0.1)'), findsOneWidget);
});
testWidgets('새 버전 → 업데이트 → 받기 → 지금 설치', (tester) async {
up.next = parseLatestRelease(_release('v1.1.0', body: '- 재생목록 전체 받기'));
await open(tester);
expect(find.text('새 버전 v1.1.0'), findsOneWidget);
expect(find.text('지금 쓰는 버전: v1.0.1 → 새 버전: v1.1.0'), findsOneWidget);
expect(find.text('- 재생목록 전체 받기'), findsOneWidget);
await tester.tap(find.text('업데이트'));
await tester.pumpAndSettle();
expect(find.text('설치 준비 완료'), findsOneWidget);
await tester.tap(find.text('지금 설치'));
await tester.pumpAndSettle();
expect(up.calls, ['download 1.1.0', r'install C:\tmp\new']);
});
testWidgets('이 버전 건너뛰기 → 자동 확인에서 다시 묻지 않음', (tester) async {
up.next = parseLatestRelease(_release('v1.1.0'));
await open(tester);
await tester.tap(find.text('이 버전 건너뛰기'));
await tester.pumpAndSettle();
expect(c.settings.skippedVersion, '1.1.0');
c.settings.lastUpdateCheck = '';
await open(tester, manual: false);
expect(find.text('새 버전 v1.1.0'), findsNothing);
});
});
}