diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 59b4cc4..028bcfd 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -402,6 +402,74 @@ class AppController extends ChangeNotifier { return n; } + /// 동영상을 이동 폴더 (환경 설정) 로 옮긴다. 옆에 있는 같은 이름의 자막 파일 (a.srt · a.ko.smi …) 도 함께. + /// 옮긴 동영상은 목록에서 뺀다. 작업 중인 것은 옮기지 않는다. 반환: (옮긴 수, 못 옮긴 이유들) + Future<(int, List)> moveVideos(List targets) async { + final dest = settings.moveTargetDir; + if (dest == null || dest.isEmpty) return (0, ['이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)']); + var moved = 0; + final errors = []; + 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 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) { if (v.status == JobStatus.running) return; videos.remove(v); diff --git a/lib/app/settings.dart b/lib/app/settings.dart index b2c09ab..5be52b9 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -13,6 +13,15 @@ class AppSettings { /// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv String? mkvOutputRoot; + /// MKV 목록의 [이동] · 세 번 누르기로 동영상을 옮길 폴더. null 이면 처음 옮길 때 고른다 + String? moveTargetDir; + + /// 웹 브라우저에서 YouTube 광고 자동 건너뛰기 (건너뛰기 버튼 누르기 · 건너뛸 수 없는 광고는 빨리 감기 · 소리 끄기) + bool youtubeAdSkip = true; + + /// YouTube 페이지의 광고 배너 · 광고 영역 숨기기 + bool youtubeAdHide = true; + /// 다운로드 위치 (아래에 jj_yt-dlp, jj_aria2 생성). null 이면 프로그램 폴더 String? downloadRoot; @@ -115,6 +124,9 @@ class AppSettings { Map toJson() => { 'mkvOutputRoot': mkvOutputRoot, + 'moveTargetDir': moveTargetDir, + 'youtubeAdSkip': youtubeAdSkip, + 'youtubeAdHide': youtubeAdHide, 'downloadRoot': downloadRoot, 'clipboardWatch': clipboardWatch, 'minimizeToTray': minimizeToTray, @@ -163,6 +175,9 @@ class AppSettings { final e = (j['encode'] as Map?) ?? const {}; return AppSettings() ..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? ..clipboardWatch = j['clipboardWatch'] as bool? ?? true ..minimizeToTray = j['minimizeToTray'] as bool? ?? true diff --git a/lib/ui/folder_picker.dart b/lib/ui/folder_picker.dart new file mode 100644 index 0000000..1f4fb86 --- /dev/null +++ b/lib/ui/folder_picker.dart @@ -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 pickFolder(BuildContext context, String title, [String? initial]) => Platform.isAndroid + ? showAndroidFolderBrowser(context, title: title, initialDirectory: initial) + : FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial); diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index f091ed7..8c5c845 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -13,6 +14,7 @@ import '../core/output_paths.dart'; import 'ai_dialog.dart'; import 'app_actions.dart'; import 'downloads_page.dart'; +import 'folder_picker.dart'; import 'player_page.dart'; import 'subtitle_editor_page.dart'; import 'subtitle_search_dialog.dart'; @@ -246,12 +248,12 @@ class _TopBar extends StatelessWidget { message: 'MKV 만들기$count', child: mkvText ? 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), label: Text('MKV 만들기$count${c.busy && jobText ? ' (대기열)' : ''}'), ) : 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), ), ), @@ -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 Widget build(BuildContext context) { final isSel = c.selected == v; final subs = v.subtitles.where((s) => s.enabled).length; return InkWell( - onTap: () => c.select(v), - onDoubleTap: () => playFiles(context, c, [v.path]), + // 한 번: 보기 · 두 번: 재생 · 세 번 이상: 이동 폴더로 옮기기 + onTap: () => _tapped(context), // 오른쪽 클릭: 이 동영상으로 할 수 있는 일 onSecondaryTapDown: (d) => _menu(context, d.globalPosition), child: Container( @@ -666,8 +691,33 @@ class _VideoDetail extends StatelessWidget { // 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다) 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( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 88), children: [ Row(children: [ Expanded( @@ -978,3 +1028,20 @@ class _LogStrip extends StatelessWidget { ); } +/// 동영상을 이동 폴더로 옮긴다 (MKV 목록의 [이동] · 세 번 누르기). 이동 폴더를 아직 안 정했으면 먼저 고른다. +Future moveToTarget(BuildContext context, AppController c, List 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')), + )); +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 3943b59..10d0d41 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -15,7 +15,7 @@ import 'update_dialog.dart'; import '../app/settings.dart'; import '../platform/windows/cef_runtime.dart'; import '../platform/android/android_shell.dart' show applyScreenOrientation; -import 'android_file_browser.dart'; +import 'folder_picker.dart'; import 'app_actions.dart'; import 'cef_setup.dart'; import 'theme.dart'; @@ -224,9 +224,7 @@ class _SettingsPageState extends State { } /// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음) - Future _pickDir(String title, [String? initial]) => Platform.isAndroid - ? showAndroidFolderBrowser(context, title: title, initialDirectory: initial) - : FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial); + Future _pickDir(String title, [String? initial]) => pickFolder(context, title, initial); Future _applyHotkey() async { final text = _hotkey.text.trim(); @@ -415,6 +413,16 @@ class _SettingsPageState extends State { }, 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( title: '다운로드 (jj_yt-dlp · jj_aria2)', value: s.downloadRoot, diff --git a/test/move_test.dart b/test/move_test.dart new file mode 100644 index 0000000..62b1cb4 --- /dev/null +++ b/test/move_test.dart @@ -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 settleIo() async { + for (var i = 0; i < 10; i++) { + await tester.runAsync(() => Future.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']); + }); +}