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
parent
6d6e1f83db
commit
cc6974fc30
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
@ -61,6 +61,9 @@ class AppController extends ChangeNotifier {
|
|||
|
||||
AppController(this.services, {this.settingsStore});
|
||||
|
||||
/// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용
|
||||
Future<bool> Function()? confirmQuit;
|
||||
|
||||
/// 설정 변경 후 저장·적용
|
||||
Future<void> updateSettings(void Function(AppSettings s) change) async {
|
||||
change(settings);
|
||||
|
|
@ -650,12 +653,21 @@ class AppController extends ChangeNotifier {
|
|||
Future<void> buildAll() async {
|
||||
if (busy || ffmpegVersion == null) return;
|
||||
busy = true;
|
||||
_buildCancelled = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
for (final v in videos) {
|
||||
if (v.status == JobStatus.done) continue;
|
||||
await _build(v);
|
||||
// 동시에 [maxParallelJobs] 개씩 (0 = 무제한). 작업자가 목록에서 하나씩 가져가 처리한다.
|
||||
final queue = videos.where((v) => v.status != JobStatus.done).toList();
|
||||
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;
|
||||
_log('완료: 성공 $ok / 전체 ${videos.length}');
|
||||
} finally {
|
||||
|
|
@ -701,7 +713,10 @@ class AppController extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _buildCancelled = false;
|
||||
|
||||
void cancel() {
|
||||
_buildCancelled = true;
|
||||
_aiCancelled = true;
|
||||
_translator?.cancel();
|
||||
_tool.cancel();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../core/download_detect.dart';
|
||||
import '../services/downloader.dart';
|
||||
|
|
@ -100,11 +101,81 @@ class DownloadManager extends ChangeNotifier {
|
|||
);
|
||||
tasks.insert(0, 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();
|
||||
unawaited(backend.start(t, notifyListeners));
|
||||
_pump();
|
||||
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));
|
||||
|
||||
void toggle(DownloadTask t) {
|
||||
|
|
@ -130,17 +201,20 @@ class DownloadManager extends ChangeNotifier {
|
|||
await backends[t.kind]!.pause(t);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// 다시 받기: 대기열에 넣고 차례가 되면 시작
|
||||
Future<void> resume(Iterable<DownloadTask> list) async {
|
||||
for (final t in list.toList()) {
|
||||
if (t.state == DownloadState.paused || t.state == DownloadState.failed) {
|
||||
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()) {
|
||||
if (t.unfinished) await backends[t.kind]!.cancel(t);
|
||||
}
|
||||
notifyListeners();
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// 목록에서 삭제 (진행 중이면 취소 후 삭제)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../core/download_detect.dart';
|
||||
import '../core/encode_options.dart';
|
||||
import '../core/playlist.dart';
|
||||
|
||||
|
|
@ -32,6 +33,33 @@ class AppSettings {
|
|||
String openSubtitlesUser = '';
|
||||
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;
|
||||
|
||||
|
|
@ -58,6 +86,17 @@ class AppSettings {
|
|||
'openSubtitlesKey': openSubtitlesKey,
|
||||
'openSubtitlesUser': openSubtitlesUser,
|
||||
'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,
|
||||
'externalPlayers': externalPlayers,
|
||||
'encode': {
|
||||
|
|
@ -84,6 +123,17 @@ class AppSettings {
|
|||
..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? ?? ''
|
||||
..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)
|
||||
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>()
|
||||
..encode = EncodeSettings(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -64,6 +64,83 @@ YtDlpProgress? parseYtDlpProgress(String line) {
|
|||
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) {
|
||||
if (b < 1024) return '${b.round()}B';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'ui/downloads_page.dart';
|
|||
import 'ui/home_page.dart';
|
||||
import 'ui/player_page.dart';
|
||||
import 'ui/setup_dialog.dart';
|
||||
import 'ui/update_dialog.dart';
|
||||
import 'ui/theme.dart';
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
|
|
@ -31,7 +32,7 @@ Future<void> main(List<String> args) async {
|
|||
await controller.init();
|
||||
|
||||
final downloads = DownloadManager(
|
||||
backends: services.createDownloadBackends?.call() ?? const [],
|
||||
backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [],
|
||||
settings: () => controller.settings,
|
||||
);
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
|
@ -85,10 +86,15 @@ Future<void> main(List<String> args) async {
|
|||
}
|
||||
});
|
||||
|
||||
// 처음 화면이 뜬 뒤 필수 프로그램 점검 (없으면 물어보고 설치)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
controller.confirmQuit = confirmQuit;
|
||||
|
||||
// 처음 화면이 뜬 뒤: 필수 프로그램 점검 (없으면 물어보고 설치) → 새 버전 확인 (하루 한 번)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -7,10 +7,23 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
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 공용.
|
||||
class MediaKitFullPlayer implements MediaPlayer {
|
||||
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 _errors = StreamController<String>.broadcast();
|
||||
List<String> _files = [];
|
||||
|
|
@ -21,7 +34,7 @@ class MediaKitFullPlayer implements MediaPlayer {
|
|||
@override
|
||||
final ValueNotifier<PlayerState> state = ValueNotifier(const PlayerState());
|
||||
|
||||
MediaKitFullPlayer() {
|
||||
MediaKitFullPlayer({this.hardwareAcceleration = true}) {
|
||||
final s = _p.stream;
|
||||
_subs
|
||||
..add(s.position.listen((_) => _emit()))
|
||||
|
|
@ -38,7 +51,7 @@ class MediaKitFullPlayer implements MediaPlayer {
|
|||
_externalSub = null; // 다음 파일로 넘어가면 외부 자막은 풀림
|
||||
_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) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart';
|
|||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../services/preview_player.dart';
|
||||
import 'media_kit_full_player.dart' show isNonFatalMpvMessage;
|
||||
|
||||
/// media_kit(libmpv) 기반 재생기. Windows·Android 공용.
|
||||
class MediaKitPreviewPlayer implements PreviewPlayer {
|
||||
|
|
@ -31,7 +32,7 @@ class MediaKitPreviewPlayer implements PreviewPlayer {
|
|||
@override
|
||||
Stream<bool> get playingStream => _player.stream.playing;
|
||||
@override
|
||||
Stream<String> get errorStream => _player.stream.error;
|
||||
Stream<String> get errorStream => _player.stream.error.where((m) => !isNonFatalMpvMessage(m));
|
||||
|
||||
@override
|
||||
Duration get position => _player.state.position;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ class Aria2Backend implements DownloadBackend {
|
|||
@override
|
||||
DownloadKind get kind => DownloadKind.torrent;
|
||||
|
||||
@override
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||
|
||||
static String _randomToken() {
|
||||
final r = math.Random.secure();
|
||||
return List.generate(24, (_) => r.nextInt(36).toRadixString(36)).join();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -11,8 +11,11 @@ import '../../services/media_tool.dart';
|
|||
class ProcessMediaTool implements MediaTool {
|
||||
final String ffmpeg;
|
||||
final String ffprobe;
|
||||
Process? _running;
|
||||
bool _cancelled = false;
|
||||
/// 실행 중인 ffmpeg (여러 개 동시 실행 가능)
|
||||
final Set<Process> _running = {};
|
||||
|
||||
/// 취소로 종료시킨 프로세스
|
||||
final Set<Process> _killed = {};
|
||||
|
||||
ProcessMediaTool(this.ffmpeg, this.ffprobe);
|
||||
|
||||
|
|
@ -105,14 +108,13 @@ class ProcessMediaTool implements MediaTool {
|
|||
Duration? duration,
|
||||
ProgressCallback? onProgress,
|
||||
}) async {
|
||||
_cancelled = false;
|
||||
final Process proc;
|
||||
try {
|
||||
proc = await Process.start(ffmpeg, args);
|
||||
} on ProcessException catch (e) {
|
||||
throw MediaToolException('ffmpeg 를 실행할 수 없습니다: ${e.message}');
|
||||
}
|
||||
_running = proc;
|
||||
_running.add(proc);
|
||||
|
||||
final totalUs = duration?.inMicroseconds ?? 0;
|
||||
final stdoutDone = proc.stdout
|
||||
|
|
@ -138,22 +140,23 @@ class ProcessMediaTool implements MediaTool {
|
|||
|
||||
final code = await proc.exitCode;
|
||||
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) {
|
||||
throw MediaToolException('ffmpeg 오류 (코드 $code)\n${errTail.join('\n')}');
|
||||
}
|
||||
onProgress?.call(1.0);
|
||||
}
|
||||
|
||||
/// 실행 중인 ffmpeg 모두 중단
|
||||
@override
|
||||
void cancel() {
|
||||
final proc = _running;
|
||||
if (proc == null) return;
|
||||
_cancelled = true;
|
||||
// 'q' 로 정상 종료를 요청하지 않고 즉시 중단 (-nostdin 사용 중)
|
||||
proc.kill();
|
||||
for (final proc in _running.toList()) {
|
||||
_killed.add(proc);
|
||||
// 'q' 로 정상 종료를 요청하지 않고 즉시 중단 (-nostdin 사용 중)
|
||||
proc.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,58 @@ class YtDlpBackend implements DownloadBackend {
|
|||
final _procs = <String, Process>{};
|
||||
final _stopping = <String>{};
|
||||
|
||||
YtDlpBackend({String? ytdlp, this.ffmpegDir, String? deno})
|
||||
: ytdlp = ytdlp ?? locateTool('yt-dlp'),
|
||||
deno = deno ?? _existing(locateTool('deno'));
|
||||
/// 형식 · 화질 인수 (환경 설정에서 읽음). 없으면 MP4 최고 화질.
|
||||
final List<String> Function() formatArgs;
|
||||
|
||||
/// 쿠키 인수 (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;
|
||||
|
||||
|
|
@ -51,6 +100,8 @@ class YtDlpBackend implements DownloadBackend {
|
|||
'--print', 'after_move:[JJF]%(filepath)s',
|
||||
'-P', t.dir,
|
||||
'-o', '%(title).150B [%(id)s].%(ext)s',
|
||||
...formatArgs(),
|
||||
...cookieArgs(),
|
||||
if (ffmpegDir != null) ...['--ffmpeg-location', ffmpegDir!],
|
||||
if (deno != null) ...['--js-runtimes', 'deno:$deno'],
|
||||
t.source,
|
||||
|
|
@ -67,8 +118,7 @@ class YtDlpBackend implements DownloadBackend {
|
|||
|
||||
final Process proc;
|
||||
try {
|
||||
proc = await Process.start(ytdlp, _args(t),
|
||||
environment: {'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'});
|
||||
proc = await Process.start(ytdlp, _args(t), environment: _env);
|
||||
} on ProcessException catch (e) {
|
||||
t
|
||||
..state = DownloadState.failed
|
||||
|
|
@ -124,7 +174,8 @@ class YtDlpBackend implements DownloadBackend {
|
|||
} else {
|
||||
t
|
||||
..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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,14 +30,27 @@ class DownloadTask {
|
|||
|
||||
bool get unfinished =>
|
||||
state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused;
|
||||
|
||||
/// 재생목록을 불러오는 중인 임시 항목 (다 불러오면 영상별 항목으로 바뀜)
|
||||
bool expanding = false;
|
||||
}
|
||||
|
||||
/// 다운로드 엔진 경계.
|
||||
/// Windows: yt-dlp.exe 프로세스 / aria2c.exe RPC (platform/windows)
|
||||
/// Android: (이식 시) youtubedl-android · aria2 안드로이드 빌드
|
||||
/// 재생목록 안의 항목
|
||||
class PlaylistEntry {
|
||||
final String url;
|
||||
final String title;
|
||||
const PlaylistEntry(this.url, this.title);
|
||||
}
|
||||
|
||||
abstract class DownloadBackend {
|
||||
DownloadKind get kind;
|
||||
|
||||
/// 재생목록이면 (목록 이름, 항목들). 재생목록이 아니거나 지원하지 않으면 null.
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url);
|
||||
|
||||
/// 시작 (또는 일시정지 후 다시 시작). 상태가 바뀔 때마다 [changed] 호출.
|
||||
Future<void> start(DownloadTask t, void Function() changed);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import 'package:path/path.dart' as p;
|
|||
import '../platform/common/media_kit_full_player.dart';
|
||||
import '../platform/common/media_kit_player.dart';
|
||||
import '../app/settings.dart';
|
||||
import '../core/download_detect.dart';
|
||||
import '../platform/common/nllb_translator.dart';
|
||||
import '../platform/common/opensubtitles_provider.dart';
|
||||
import '../platform/common/whisper_recognizer.dart';
|
||||
import '../platform/windows/aria2_backend.dart';
|
||||
import '../platform/windows/desktop_shell.dart';
|
||||
import '../platform/windows/desktop_storage_service.dart';
|
||||
import '../platform/windows/github_updater.dart';
|
||||
import '../platform/windows/process_media_tool.dart';
|
||||
import '../platform/windows/ytdlp_backend.dart';
|
||||
import 'ai_services.dart';
|
||||
|
|
@ -22,6 +24,7 @@ import 'model_store.dart';
|
|||
import 'preview_player.dart';
|
||||
import 'storage_service.dart';
|
||||
import 'subtitle_provider.dart';
|
||||
import 'updater.dart';
|
||||
|
||||
/// 플랫폼별 구현을 한 곳에서 고른다.
|
||||
/// Android 이식 시 이 파일에 분기만 추가하면 된다.
|
||||
|
|
@ -40,12 +43,15 @@ class PlatformServices {
|
|||
final Translator Function()? createTranslator;
|
||||
final ModelStore models;
|
||||
|
||||
/// 다운로드 엔진 (yt-dlp, aria2)
|
||||
final List<DownloadBackend> Function()? createDownloadBackends;
|
||||
/// 다운로드 엔진 (yt-dlp, aria2). 설정값(형식 · 화질)을 읽는 함수를 받는다.
|
||||
final List<DownloadBackend> Function(AppSettings Function() settings)? createDownloadBackends;
|
||||
|
||||
/// 트레이 · 전역 단축키 · 창 닫기
|
||||
final AppShell shell;
|
||||
|
||||
/// 새 버전 확인 · 설치 (없으면 업데이트 기능 숨김)
|
||||
final Updater? updater;
|
||||
|
||||
/// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다)
|
||||
final List<SubtitleProvider> Function(AppSettings Function() settings)? createSubtitleProviders;
|
||||
|
||||
|
|
@ -58,6 +64,7 @@ class PlatformServices {
|
|||
this.createTranslator,
|
||||
this.createDownloadBackends,
|
||||
this.createSubtitleProviders,
|
||||
this.updater,
|
||||
ModelStore? models,
|
||||
AppShell? shell,
|
||||
}) : models = models ?? ModelStore(),
|
||||
|
|
@ -75,11 +82,16 @@ class PlatformServices {
|
|||
createMediaPlayer: MediaKitFullPlayer.new,
|
||||
createRecognizer: WhisperRecognizer.new,
|
||||
createTranslator: NllbTranslator.new,
|
||||
createDownloadBackends: () => [
|
||||
YtDlpBackend(ffmpegDir: ffmpegDir),
|
||||
createDownloadBackends: (s) => [
|
||||
YtDlpBackend(
|
||||
ffmpegDir: ffmpegDir,
|
||||
formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality),
|
||||
cookieArgs: () => ytDlpCookieArgs(browser: s().ytCookiesBrowser, file: s().ytCookiesFile),
|
||||
),
|
||||
Aria2Backend(),
|
||||
],
|
||||
shell: DesktopShell(),
|
||||
updater: GitHubUpdater(),
|
||||
createSubtitleProviders: (s) => [
|
||||
OpenSubtitlesProvider(
|
||||
apiKey: () => s().openSubtitlesKey,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -161,7 +161,6 @@ class _DownloadRow extends StatelessWidget {
|
|||
DownloadState.failed => ('실패', JjColors.danger),
|
||||
DownloadState.cancelled => ('취소됨', JjColors.textDim),
|
||||
};
|
||||
final pct = t.progress == null ? '' : '${(t.progress! * 100).toStringAsFixed(1)}%';
|
||||
return InkWell(
|
||||
onTap: () => d.toggle(t),
|
||||
child: Container(
|
||||
|
|
@ -180,13 +179,11 @@ class _DownloadRow extends StatelessWidget {
|
|||
Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
if (t.state == DownloadState.downloading || t.state == DownloadState.paused)
|
||||
LinearProgressIndicator(value: t.progress, minHeight: 3),
|
||||
const SizedBox(height: 3),
|
||||
if (t.state != DownloadState.cancelled) DownloadProgressBar(t: t),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
label,
|
||||
if (pct.isNotEmpty) pct,
|
||||
if (t.speed.isNotEmpty) t.speed,
|
||||
if (t.eta.isNotEmpty) '남은 시간 ${t.eta}',
|
||||
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 {
|
||||
if (d.activeCount == 0) return true;
|
||||
|
|
@ -269,9 +316,10 @@ class _ExitDialog extends StatelessWidget {
|
|||
? Icons.smart_display_outlined
|
||||
: Icons.cloud_download_outlined),
|
||||
title: Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: t.progress == null
|
||||
? null
|
||||
: LinearProgressIndicator(value: t.progress, minHeight: 3),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: DownloadProgressBar(t: t, height: 14),
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: '삭제 (중지하고 받던 파일 삭제)',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
|
|
|
|||
|
|
@ -71,7 +71,19 @@ class HomePage extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
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,
|
||||
color: n > 0 ? JjColors.accent : JjColors.textDim),
|
||||
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)),
|
||||
]),
|
||||
if (n > 0) ...[
|
||||
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 {
|
||||
final List<String> logs;
|
||||
const _LogPanel({required this.logs});
|
||||
final VoidCallback onClose;
|
||||
const _LogPanel({required this.logs, required this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
color: JjColors.bg,
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (_, i) => SelectableText(
|
||||
logs[logs.length - 1 - i],
|
||||
style: const TextStyle(
|
||||
fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim),
|
||||
child: Stack(children: [
|
||||
Positioned.fill(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 44, 8),
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (_, i) => SelectableText(
|
||||
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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,14 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||
super.initState();
|
||||
pl.open(widget.files, start: widget.start);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,13 +3,87 @@ import 'dart:io';
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../core/download_detect.dart';
|
||||
import '../core/playlist.dart';
|
||||
import '../core/subtitle_detector.dart';
|
||||
import '../services/app_shell.dart';
|
||||
import 'setup_dialog.dart';
|
||||
import 'update_dialog.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 / 직접 지정)
|
||||
class _ExternalPlayers extends StatelessWidget {
|
||||
final AppController c;
|
||||
|
|
@ -234,6 +308,84 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
(v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())),
|
||||
_textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 PC 의 설정 파일에 저장됩니다',
|
||||
(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('실행'),
|
||||
SwitchListTile(
|
||||
value: s.minimizeToTray,
|
||||
|
|
@ -270,6 +422,24 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
'언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'),
|
||||
),
|
||||
_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(
|
||||
title: const Text('필수 프로그램 점검'),
|
||||
subtitle: const Text('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다'),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -106,7 +106,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.3.5+5"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
|
@ -544,7 +544,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.0.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||
|
|
|
|||
|
|
@ -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.0+1
|
||||
version: 1.0.1+2
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.4
|
||||
|
|
@ -52,6 +52,8 @@ dependencies:
|
|||
hotkey_manager: ^0.2.3
|
||||
desktop_drop: ^0.8.0
|
||||
screen_retriever: ^0.2.2
|
||||
package_info_plus: ^10.2.1
|
||||
crypto: ^3.0.7
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ class _FakeBackend implements DownloadBackend {
|
|||
final started = <String>[];
|
||||
_FakeBackend(this.kind);
|
||||
|
||||
/// 재생목록 흉내 (null 이면 재생목록 아님)
|
||||
(String, List<PlaylistEntry>)? playlist;
|
||||
|
||||
@override
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => playlist;
|
||||
|
||||
@override
|
||||
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||
started.add(t.source);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ class _Backend implements DownloadBackend {
|
|||
final DownloadKind kind;
|
||||
_Backend(this.kind);
|
||||
@override
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||
@override
|
||||
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||
t
|
||||
..state = DownloadState.downloading
|
||||
|
|
@ -51,7 +53,10 @@ void main() {
|
|||
await tester.pump();
|
||||
expect(find.text('테스트 영상'), 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.pump();
|
||||
|
|
@ -61,8 +66,12 @@ void main() {
|
|||
expect(d.tasks.every((t) => t.state == DownloadState.paused), isTrue);
|
||||
|
||||
d.tasks.first.state = DownloadState.done;
|
||||
d.tasks.last.progress = null; // 진행률을 모르는 상태 (마그넷 정보 받는 중 등)
|
||||
d.tasks.last.state = DownloadState.downloading;
|
||||
d.selectNone(); // 화면 갱신
|
||||
await tester.pump();
|
||||
expect(find.text('100.0%'), findsOneWidget); // 완료는 가득 찬 막대
|
||||
expect(find.text('준비 중…'), findsOneWidget);
|
||||
await tester.tap(find.text('완료 정리'));
|
||||
await tester.pump();
|
||||
expect(d.tasks, hasLength(1));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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)));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue