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});
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 목록에서 삭제 (진행 중이면 취소 후 삭제)
|
/// 목록에서 삭제 (진행 중이면 취소 후 삭제)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
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';
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 가 없으면 내려받아 설치합니다'),
|
||||||
|
|
|
||||||
|
|
@ -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
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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