MKV 목록: 선택 0개면 보고 있는 동영상만 만들기, [이동] 버튼 · 세 번 누르면 이동, 이동 폴더 설정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-04 13:43:48 +09:00
parent 2a4529392c
commit fb92349176
6 changed files with 287 additions and 9 deletions

View File

@ -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);

View File

@ -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

11
lib/ui/folder_picker.dart Normal file
View File

@ -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);

View File

@ -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')),
));
}

View File

@ -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,

109
test/move_test.dart Normal file
View File

@ -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']);
});
}