MKV 목록: 선택 0개면 보고 있는 동영상만 만들기, [이동] 버튼 · 세 번 누르면 이동, 이동 폴더 설정
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
2a4529392c
commit
fb92349176
|
|
@ -402,6 +402,74 @@ class AppController extends ChangeNotifier {
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 동영상을 이동 폴더 (환경 설정) 로 옮긴다. 옆에 있는 같은 이름의 자막 파일 (a.srt · a.ko.smi …) 도 함께.
|
||||||
|
/// 옮긴 동영상은 목록에서 뺀다. 작업 중인 것은 옮기지 않는다. 반환: (옮긴 수, 못 옮긴 이유들)
|
||||||
|
Future<(int, List<String>)> moveVideos(List<VideoItem> targets) async {
|
||||||
|
final dest = settings.moveTargetDir;
|
||||||
|
if (dest == null || dest.isEmpty) return (0, ['이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)']);
|
||||||
|
var moved = 0;
|
||||||
|
final errors = <String>[];
|
||||||
|
try {
|
||||||
|
await Directory(dest).create(recursive: true);
|
||||||
|
} catch (e) {
|
||||||
|
return (0, ['이동할 폴더를 만들 수 없습니다: $dest ($e)']);
|
||||||
|
}
|
||||||
|
for (final v in List.of(targets)) {
|
||||||
|
if (v.status == JobStatus.running) {
|
||||||
|
errors.add('${v.fileName}: 작업 중이라 옮기지 않았습니다');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p.equals(v.directory, dest)) {
|
||||||
|
errors.add('${v.fileName}: 이미 이동 폴더에 있습니다');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final side = {
|
||||||
|
for (final s in v.subtitles)
|
||||||
|
if (s.path != null &&
|
||||||
|
p.equals(p.dirname(s.path!), v.directory) &&
|
||||||
|
p.basename(s.path!).startsWith('${v.baseName}.') &&
|
||||||
|
File(s.path!).existsSync())
|
||||||
|
s.path!,
|
||||||
|
};
|
||||||
|
final to = await moveFileInto(v.path, dest);
|
||||||
|
for (final s in side) {
|
||||||
|
try {
|
||||||
|
await moveFileInto(s, dest);
|
||||||
|
} catch (e) {
|
||||||
|
_log('자막 이동 실패: ${p.basename(s)} ($e)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_log('이동: ${v.fileName} → $to${side.isEmpty ? '' : ' (자막 ${side.length}개 함께)'}');
|
||||||
|
videos.remove(v);
|
||||||
|
checked.remove(v);
|
||||||
|
moved++;
|
||||||
|
} catch (e) {
|
||||||
|
errors.add('${v.fileName}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull;
|
||||||
|
_saveVideoList();
|
||||||
|
notifyListeners();
|
||||||
|
return (moved, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 파일을 폴더 안으로 옮긴다 (같은 이름이 있으면 "이름 (2).mp4"). 다른 드라이브 · SD 카드면 복사 후 지운다. 옮긴 경로를 돌려준다.
|
||||||
|
static Future<String> moveFileInto(String from, String dir) async {
|
||||||
|
final stem = p.basenameWithoutExtension(from), ext = p.extension(from);
|
||||||
|
var to = p.join(dir, p.basename(from));
|
||||||
|
for (var n = 2; File(to).existsSync(); n++) {
|
||||||
|
to = p.join(dir, '$stem ($n)$ext');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await File(from).rename(to);
|
||||||
|
} on FileSystemException {
|
||||||
|
await File(from).copy(to);
|
||||||
|
await File(from).delete();
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
|
||||||
void removeVideo(VideoItem v) {
|
void removeVideo(VideoItem v) {
|
||||||
if (v.status == JobStatus.running) return;
|
if (v.status == JobStatus.running) return;
|
||||||
videos.remove(v);
|
videos.remove(v);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,15 @@ class AppSettings {
|
||||||
/// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv
|
/// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv
|
||||||
String? mkvOutputRoot;
|
String? mkvOutputRoot;
|
||||||
|
|
||||||
|
/// MKV 목록의 [이동] · 세 번 누르기로 동영상을 옮길 폴더. null 이면 처음 옮길 때 고른다
|
||||||
|
String? moveTargetDir;
|
||||||
|
|
||||||
|
/// 웹 브라우저에서 YouTube 광고 자동 건너뛰기 (건너뛰기 버튼 누르기 · 건너뛸 수 없는 광고는 빨리 감기 · 소리 끄기)
|
||||||
|
bool youtubeAdSkip = true;
|
||||||
|
|
||||||
|
/// YouTube 페이지의 광고 배너 · 광고 영역 숨기기
|
||||||
|
bool youtubeAdHide = true;
|
||||||
|
|
||||||
/// 다운로드 위치 (아래에 jj_yt-dlp, jj_aria2 생성). null 이면 프로그램 폴더
|
/// 다운로드 위치 (아래에 jj_yt-dlp, jj_aria2 생성). null 이면 프로그램 폴더
|
||||||
String? downloadRoot;
|
String? downloadRoot;
|
||||||
|
|
||||||
|
|
@ -115,6 +124,9 @@ class AppSettings {
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'mkvOutputRoot': mkvOutputRoot,
|
'mkvOutputRoot': mkvOutputRoot,
|
||||||
|
'moveTargetDir': moveTargetDir,
|
||||||
|
'youtubeAdSkip': youtubeAdSkip,
|
||||||
|
'youtubeAdHide': youtubeAdHide,
|
||||||
'downloadRoot': downloadRoot,
|
'downloadRoot': downloadRoot,
|
||||||
'clipboardWatch': clipboardWatch,
|
'clipboardWatch': clipboardWatch,
|
||||||
'minimizeToTray': minimizeToTray,
|
'minimizeToTray': minimizeToTray,
|
||||||
|
|
@ -163,6 +175,9 @@ class AppSettings {
|
||||||
final e = (j['encode'] as Map?) ?? const {};
|
final e = (j['encode'] as Map?) ?? const {};
|
||||||
return AppSettings()
|
return AppSettings()
|
||||||
..mkvOutputRoot = j['mkvOutputRoot'] as String?
|
..mkvOutputRoot = j['mkvOutputRoot'] as String?
|
||||||
|
..moveTargetDir = j['moveTargetDir'] as String?
|
||||||
|
..youtubeAdSkip = j['youtubeAdSkip'] as bool? ?? true
|
||||||
|
..youtubeAdHide = j['youtubeAdHide'] as bool? ?? true
|
||||||
..downloadRoot = j['downloadRoot'] as String?
|
..downloadRoot = j['downloadRoot'] as String?
|
||||||
..clipboardWatch = j['clipboardWatch'] as bool? ?? true
|
..clipboardWatch = j['clipboardWatch'] as bool? ?? true
|
||||||
..minimizeToTray = j['minimizeToTray'] as bool? ?? true
|
..minimizeToTray = j['minimizeToTray'] as bool? ?? true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'android_file_browser.dart';
|
||||||
|
|
||||||
|
/// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음), PC 는 Windows 폴더 선택 창
|
||||||
|
Future<String?> pickFolder(BuildContext context, String title, [String? initial]) => Platform.isAndroid
|
||||||
|
? showAndroidFolderBrowser(context, title: title, initialDirectory: initial)
|
||||||
|
: FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial);
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -13,6 +14,7 @@ import '../core/output_paths.dart';
|
||||||
import 'ai_dialog.dart';
|
import 'ai_dialog.dart';
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
import 'downloads_page.dart';
|
import 'downloads_page.dart';
|
||||||
|
import 'folder_picker.dart';
|
||||||
import 'player_page.dart';
|
import 'player_page.dart';
|
||||||
import 'subtitle_editor_page.dart';
|
import 'subtitle_editor_page.dart';
|
||||||
import 'subtitle_search_dialog.dart';
|
import 'subtitle_search_dialog.dart';
|
||||||
|
|
@ -246,12 +248,12 @@ class _TopBar extends StatelessWidget {
|
||||||
message: 'MKV 만들기$count',
|
message: 'MKV 만들기$count',
|
||||||
child: mkvText
|
child: mkvText
|
||||||
? FilledButton.icon(
|
? FilledButton.icon(
|
||||||
onPressed: !canBuild ? null : (c.checked.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)),
|
onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)),
|
||||||
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
|
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
|
||||||
label: Text('MKV 만들기$count${c.busy && jobText ? ' (대기열)' : ''}'),
|
label: Text('MKV 만들기$count${c.busy && jobText ? ' (대기열)' : ''}'),
|
||||||
)
|
)
|
||||||
: IconButton.filled(
|
: IconButton.filled(
|
||||||
onPressed: !canBuild ? null : (c.checked.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)),
|
onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)),
|
||||||
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
|
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -535,13 +537,36 @@ class _VideoTile extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 연달아 누른 횟수 (두 번이면 재생, 세 번 이상이면 이동). 마지막으로 누르고 잠시 뒤에 정한다.
|
||||||
|
static VideoItem? _tapItem;
|
||||||
|
static int _taps = 0;
|
||||||
|
static Timer? _tapTimer;
|
||||||
|
|
||||||
|
void _tapped(BuildContext context) {
|
||||||
|
c.select(v);
|
||||||
|
_taps = _tapItem == v ? _taps + 1 : 1;
|
||||||
|
_tapItem = v;
|
||||||
|
_tapTimer?.cancel();
|
||||||
|
_tapTimer = Timer(const Duration(milliseconds: 350), () {
|
||||||
|
final n = _taps;
|
||||||
|
_taps = 0;
|
||||||
|
_tapItem = null;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (n == 2) {
|
||||||
|
playFiles(context, c, [v.path]);
|
||||||
|
} else if (n >= 3) {
|
||||||
|
moveToTarget(context, c, [v]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isSel = c.selected == v;
|
final isSel = c.selected == v;
|
||||||
final subs = v.subtitles.where((s) => s.enabled).length;
|
final subs = v.subtitles.where((s) => s.enabled).length;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => c.select(v),
|
// 한 번: 보기 · 두 번: 재생 · 세 번 이상: 이동 폴더로 옮기기
|
||||||
onDoubleTap: () => playFiles(context, c, [v.path]),
|
onTap: () => _tapped(context),
|
||||||
// 오른쪽 클릭: 이 동영상으로 할 수 있는 일
|
// 오른쪽 클릭: 이 동영상으로 할 수 있는 일
|
||||||
onSecondaryTapDown: (d) => _menu(context, d.globalPosition),
|
onSecondaryTapDown: (d) => _menu(context, d.globalPosition),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -666,8 +691,33 @@ class _VideoDetail extends StatelessWidget {
|
||||||
// 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다)
|
// 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다)
|
||||||
final locked = v.status == JobStatus.running;
|
final locked = v.status == JobStatus.running;
|
||||||
|
|
||||||
|
// 오른쪽 아래 [이동]: 체크한 동영상이 있으면 그것들, 없으면 지금 보고 있는 이 동영상
|
||||||
|
final moveTargets = c.checked.isEmpty ? [v] : c.batchTargets;
|
||||||
|
return Stack(children: [
|
||||||
|
_list(context, info, video, audioCount, locked),
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: Tooltip(
|
||||||
|
message: c.checked.isEmpty
|
||||||
|
? '이동: 이 동영상을 이동 폴더로 옮깁니다${c.settings.moveTargetDir == null ? '' : ' (${c.settings.moveTargetDir})'}'
|
||||||
|
: '이동: 체크한 동영상 ${c.checked.length}개를 이동 폴더로 옮깁니다',
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
heroTag: null,
|
||||||
|
onPressed: moveTargets.any((x) => x.status != JobStatus.running)
|
||||||
|
? () => moveToTarget(context, c, moveTargets)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.drive_file_move_outline),
|
||||||
|
label: Text(c.checked.isEmpty ? '이동' : '이동 (${c.checked.length})'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked) {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 88),
|
||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -978,3 +1028,20 @@ class _LogStrip extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 동영상을 이동 폴더로 옮긴다 (MKV 목록의 [이동] · 세 번 누르기). 이동 폴더를 아직 안 정했으면 먼저 고른다.
|
||||||
|
Future<void> moveToTarget(BuildContext context, AppController c, List<VideoItem> targets) async {
|
||||||
|
if (targets.isEmpty) return;
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (c.settings.moveTargetDir == null) {
|
||||||
|
final d = await pickFolder(context, '이동 폴더 (환경 설정에서 바꿀 수 있음)');
|
||||||
|
if (d == null) return;
|
||||||
|
await c.updateSettings((x) => x.moveTargetDir = d);
|
||||||
|
}
|
||||||
|
final (moved, errors) = await c.moveVideos(targets);
|
||||||
|
messenger?.showSnackBar(SnackBar(
|
||||||
|
content: Text([
|
||||||
|
if (moved > 0) '$moved개를 옮겼습니다 → ${c.settings.moveTargetDir}',
|
||||||
|
...errors,
|
||||||
|
].join('\n')),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import 'update_dialog.dart';
|
||||||
import '../app/settings.dart';
|
import '../app/settings.dart';
|
||||||
import '../platform/windows/cef_runtime.dart';
|
import '../platform/windows/cef_runtime.dart';
|
||||||
import '../platform/android/android_shell.dart' show applyScreenOrientation;
|
import '../platform/android/android_shell.dart' show applyScreenOrientation;
|
||||||
import 'android_file_browser.dart';
|
import 'folder_picker.dart';
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
import 'cef_setup.dart';
|
import 'cef_setup.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
@ -224,9 +224,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음)
|
/// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음)
|
||||||
Future<String?> _pickDir(String title, [String? initial]) => Platform.isAndroid
|
Future<String?> _pickDir(String title, [String? initial]) => pickFolder(context, title, initial);
|
||||||
? showAndroidFolderBrowser(context, title: title, initialDirectory: initial)
|
|
||||||
: FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial);
|
|
||||||
|
|
||||||
Future<void> _applyHotkey() async {
|
Future<void> _applyHotkey() async {
|
||||||
final text = _hotkey.text.trim();
|
final text = _hotkey.text.trim();
|
||||||
|
|
@ -415,6 +413,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
},
|
},
|
||||||
onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null),
|
onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null),
|
||||||
),
|
),
|
||||||
|
_folderTile(
|
||||||
|
title: '이동 폴더',
|
||||||
|
value: s.moveTargetDir,
|
||||||
|
defaultText: '정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다',
|
||||||
|
onPick: () async {
|
||||||
|
final d = await _pickDir('이동 폴더', s.moveTargetDir);
|
||||||
|
if (d != null) await c.updateSettings((x) => x.moveTargetDir = d);
|
||||||
|
},
|
||||||
|
onReset: () => c.updateSettings((x) => x.moveTargetDir = null),
|
||||||
|
),
|
||||||
_folderTile(
|
_folderTile(
|
||||||
title: '다운로드 (jj_yt-dlp · jj_aria2)',
|
title: '다운로드 (jj_yt-dlp · jj_aria2)',
|
||||||
value: s.downloadRoot,
|
value: s.downloadRoot,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
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/settings.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/languages.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/models.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/ui/home_page.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
AppController _controller() =>
|
||||||
|
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory dir;
|
||||||
|
setUp(() => dir = Directory.systemTemp.createTempSync('jj_move_'));
|
||||||
|
tearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
File make(String name) => File(p.join(dir.path, name))
|
||||||
|
..parent.createSync(recursive: true)
|
||||||
|
..writeAsStringSync(name);
|
||||||
|
|
||||||
|
test('이동: 동영상 + 옆의 같은 이름 자막을 옮기고 목록에서 뺀다. 같은 이름이 있으면 (2)', () async {
|
||||||
|
final c = _controller();
|
||||||
|
final dest = p.join(dir.path, 'done');
|
||||||
|
c.settings.moveTargetDir = dest;
|
||||||
|
final a = VideoItem(make('a.mp4').path)
|
||||||
|
..subtitles.add(SubtitleEntry.external(path: make('a.ko.srt').path, language: undetermined))
|
||||||
|
..subtitles.add(SubtitleEntry.external(path: make('other.srt').path, language: undetermined));
|
||||||
|
final b = VideoItem(make('b.mkv').path);
|
||||||
|
final busy = VideoItem(make('c.mp4').path)..status = JobStatus.running;
|
||||||
|
make(p.join('done', 'b.mkv')); // 이미 있는 이름
|
||||||
|
c.videos.addAll([a, b, busy]);
|
||||||
|
c.selected = a;
|
||||||
|
|
||||||
|
final (moved, errors) = await c.moveVideos([a, b, busy]);
|
||||||
|
expect(moved, 2);
|
||||||
|
expect(errors.single, contains('작업 중'));
|
||||||
|
expect(File(p.join(dest, 'a.mp4')).existsSync(), isTrue);
|
||||||
|
expect(File(p.join(dest, 'a.ko.srt')).existsSync(), isTrue);
|
||||||
|
expect(File(p.join(dir.path, 'other.srt')).existsSync(), isTrue, reason: '이름이 다른 자막은 그대로');
|
||||||
|
expect(File(p.join(dest, 'b (2).mkv')).readAsStringSync(), 'b.mkv');
|
||||||
|
expect(File(p.join(dir.path, 'a.mp4')).existsSync(), isFalse);
|
||||||
|
expect(c.videos, [busy]);
|
||||||
|
expect(c.selected, busy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이동 폴더를 안 정했으면 옮기지 않는다', () async {
|
||||||
|
final c = _controller();
|
||||||
|
final a = VideoItem(make('a.mp4').path);
|
||||||
|
c.videos.add(a);
|
||||||
|
final (moved, errors) = await c.moveVideos([a]);
|
||||||
|
expect(moved, 0);
|
||||||
|
expect(errors.single, contains('이동할 폴더'));
|
||||||
|
expect(File(a.path).existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이동 폴더 설정 저장', () {
|
||||||
|
final s = AppSettings()..moveTargetDir = r'D:\done';
|
||||||
|
expect(AppSettings.fromJson(s.toJson()).moveTargetDir, r'D:\done');
|
||||||
|
expect(AppSettings().moveTargetDir, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('MKV 목록: 세 번 누르면 이동 · 세부 정보의 [이동] 은 체크한 것 (없으면 보고 있는 것)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1600, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final c = _controller();
|
||||||
|
final dest = p.join(dir.path, 'done');
|
||||||
|
c.settings.moveTargetDir = dest;
|
||||||
|
final a = VideoItem(make('a.mp4').path);
|
||||||
|
final b = VideoItem(make('b.mp4').path);
|
||||||
|
final d = VideoItem(make('d.mp4').path);
|
||||||
|
c.videos.addAll([a, b, d]);
|
||||||
|
c.selected = a;
|
||||||
|
await tester.pumpWidget(MaterialApp(home: HomePage(c: c, onExit: () {})));
|
||||||
|
// 파일 옮기기 (실제 입출력) 가 끝나도록 실제 시간을 조금씩 흘려 보낸다
|
||||||
|
Future<void> settleIo() async {
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// b 줄을 세 번 → b 만 이동
|
||||||
|
final tile = find.text('b.mp4').first;
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
await tester.tap(tile);
|
||||||
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
|
}
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await settleIo();
|
||||||
|
expect(File(p.join(dest, 'b.mp4')).existsSync(), isTrue);
|
||||||
|
expect(c.videos.map((v) => v.fileName), ['a.mp4', 'd.mp4']);
|
||||||
|
|
||||||
|
// 체크 없음: [이동] → 보고 있는 동영상 (a)
|
||||||
|
c.select(a);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('이동'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('이동'));
|
||||||
|
await settleIo();
|
||||||
|
expect(File(p.join(dest, 'a.mp4')).existsSync(), isTrue);
|
||||||
|
expect(c.videos.map((v) => v.fileName), ['d.mp4']);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue