diff --git a/CLAUDE.md b/CLAUDE.md index c049bdb..0eb6632 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,7 @@ VapourBox/ | `app/lib/services/filter_loader.dart` | Load filter schemas from JSON | | `app/lib/services/preset_service.dart` | Save/load user presets | | `app/lib/services/temp_directory_service.dart` | Configurable scratch-file directory (see "Temporary Files Directory") | +| `app/lib/services/overwrite_behavior_service.dart` | Default action for existing output files (see "Existing Output Files") | | `app/assets/filters/core/*.json` | Built-in filter schema definitions | ## Build Commands @@ -797,6 +798,23 @@ download. recreated; `setOverride` verifies writability with a probe file before accepting a path. +### Existing Output Files (issue #85) + +Before a job starts, `_getConflictingItems` (`main_window.dart`) checks whether +any queued item's `outputPath` already exists. What happens next is decided by +`OverwriteBehaviorService` (persisted as `overwriteBehavior`, default `ask`, +configurable in **Settings → General → Existing Output Files**): + +- **`ask`** (default) — shows `OverwriteWarningDialog`, which now also notes + where to change the default. Cancelling aborts the job. +- **`overwrite`** — proceeds silently; the existing file is replaced. +- **`rename`** — `OverwriteBehaviorService.uniquePath` picks the first free + `name (2).ext`, `name (3).ext`, … and each conflicting item's `outputPath` is + updated to it before the job starts; the existing file is untouched. Mutating + `QueueItem.outputPath` directly needs a `notifyListeners()` call afterward — + `MainViewModel.notifyOutputPathsChanged()` is that call for callers outside + the view model. + ### Preset System Presets save complete filter pipeline + encoding settings. The built-in presets diff --git a/README.md b/README.md index e38cbd0..7a5e2a0 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,13 @@ Scratch files — generated scripts, preview frames, job files, extracted DVD ti +
+Existing output files + +When a job's output file already exists, VapourBox asks what to do by default. **Settings → General → Existing Output Files** can change that to always overwrite, or always write to a new, numbered filename (`name (2).ext`, `name (3).ext`, …) instead of touching the existing file. The dialog itself links back here. + +
+ ## Feedback and bug reports **Settings → General → "Report a bug or give feedback"** opens the issue tracker. Reports about specific problem sources are useful — several fixes in VapourBox came from them. diff --git a/app/lib/main.dart b/app/lib/main.dart index 8dc5ffd..b3ad9ce 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -7,6 +7,7 @@ import 'models/filter_registry.dart'; import 'services/advanced_mode_service.dart'; import 'services/dependency_manager.dart'; import 'services/hardware_encoder_detector.dart'; +import 'services/overwrite_behavior_service.dart'; import 'services/preset_service.dart'; import 'services/temp_directory_service.dart'; import 'services/tool_locator.dart'; @@ -30,6 +31,10 @@ void main() async { // settings don't flash from simple to advanced on startup. await AdvancedModeService.instance.initialize(); + // Load the saved default for existing output files (issue #85) before the + // first job can be started. + await OverwriteBehaviorService.instance.initialize(); + // Initialize window manager for desktop await windowManager.ensureInitialized(); diff --git a/app/lib/services/overwrite_behavior_service.dart b/app/lib/services/overwrite_behavior_service.dart new file mode 100644 index 0000000..3bec998 --- /dev/null +++ b/app/lib/services/overwrite_behavior_service.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// What VapourBox should do when a job's output file already exists on disk +/// (issue #85). +enum OverwriteBehavior { + /// Show [OverwriteWarningDialog] and let the user decide each time. + ask, + + /// Overwrite the existing file without asking. + overwrite, + + /// Write to a new, non-colliding filename instead of touching the existing + /// file. + rename; + + static OverwriteBehavior fromName(String? name) { + return OverwriteBehavior.values.firstWhere( + (behavior) => behavior.name == name, + orElse: () => OverwriteBehavior.ask, + ); + } + + String get label { + switch (this) { + case OverwriteBehavior.ask: + return 'Ask every time'; + case OverwriteBehavior.overwrite: + return 'Overwrite'; + case OverwriteBehavior.rename: + return 'Rename the new file'; + } + } + + String get description { + switch (this) { + case OverwriteBehavior.ask: + return 'Show a warning and let you choose before each job starts'; + case OverwriteBehavior.overwrite: + return 'Replace the existing file without asking'; + case OverwriteBehavior.rename: + return 'Keep the existing file and add a number to the new one'; + } + } +} + +/// Default action for output files that already exist, configurable in +/// Settings -> General. Defaults to [OverwriteBehavior.ask], which preserves +/// the previous (only) behavior of always showing +/// `OverwriteWarningDialog`. +class OverwriteBehaviorService { + static final OverwriteBehaviorService instance = + OverwriteBehaviorService._(); + OverwriteBehaviorService._(); + + static const String _prefsKey = 'overwriteBehavior'; + + OverwriteBehavior _behavior = OverwriteBehavior.ask; + bool _loaded = false; + + OverwriteBehavior get behavior => _behavior; + + /// Load the saved choice. Safe to call again; only the first call reads + /// storage. + Future initialize() async { + if (_loaded) return; + try { + final prefs = await SharedPreferences.getInstance(); + _behavior = OverwriteBehavior.fromName(prefs.getString(_prefsKey)); + } catch (_) { + // Unreadable preferences shouldn't stop the app starting - asking is + // the safe default to fall back to. + _behavior = OverwriteBehavior.ask; + } + _loaded = true; + } + + Future setBehavior(OverwriteBehavior value) async { + _behavior = value; + _loaded = true; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, value.name); + } catch (_) { + // Keep the in-memory choice for this session even if it can't be saved. + } + } + + /// Returns [path] unchanged if nothing exists there yet, otherwise the + /// first `name (2).ext`, `name (3).ext`, ... that doesn't. + Future uniquePath(String path) async { + if (!await File(path).exists()) return path; + + final separator = path.lastIndexOf(RegExp(r'[\\/]')); + final dir = separator >= 0 ? path.substring(0, separator + 1) : ''; + final name = separator >= 0 ? path.substring(separator + 1) : path; + final dot = name.lastIndexOf('.'); + final stem = dot > 0 ? name.substring(0, dot) : name; + final ext = dot > 0 ? name.substring(dot) : ''; + + var counter = 2; + while (true) { + final candidate = '$dir$stem ($counter)$ext'; + if (!await File(candidate).exists()) return candidate; + counter++; + } + } +} diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index 140a007..f03c611 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -737,6 +737,12 @@ class MainViewModel extends ChangeNotifier { notifyListeners(); } + /// Call after mutating one or more queue items' `outputPath` directly + /// (e.g. auto-renaming to avoid an overwrite) so the UI reflects it. + void notifyOutputPathsChanged() { + notifyListeners(); + } + /// Clears the current input (alias for clearQueue). void clearInput() { clearQueue(); diff --git a/app/lib/views/main_window.dart b/app/lib/views/main_window.dart index 5b5c8a5..68d90a9 100644 --- a/app/lib/views/main_window.dart +++ b/app/lib/views/main_window.dart @@ -9,6 +9,7 @@ import '../models/processing_preset.dart'; import '../models/progress_info.dart'; import '../models/queue_item.dart'; import '../services/audio_compatibility_service.dart'; +import '../services/overwrite_behavior_service.dart'; import '../services/preset_service.dart'; import '../viewmodels/main_viewmodel.dart'; import '../services/disc_detector.dart'; @@ -787,15 +788,30 @@ class MainWindow extends StatelessWidget { MainViewModel viewModel, ) async { // Check for existing output files that would be overwritten - final existingFiles = await _getExistingOutputFiles(viewModel.queue); - if (existingFiles.isNotEmpty) { - if (!context.mounted) return; - final shouldOverwrite = await OverwriteWarningDialog.show( - context: context, - existingFiles: existingFiles, - ); - if (!shouldOverwrite) { - return; // User cancelled + final conflictingItems = await _getConflictingItems(viewModel.queue); + if (conflictingItems.isNotEmpty) { + switch (OverwriteBehaviorService.instance.behavior) { + case OverwriteBehavior.overwrite: + // Proceed silently - the existing files will be replaced. + break; + case OverwriteBehavior.rename: + // Give each conflicting item a fresh, non-colliding output path + // instead of touching what's already there. + for (final item in conflictingItems) { + item.outputPath = await OverwriteBehaviorService.instance + .uniquePath(item.outputPath); + } + viewModel.notifyOutputPathsChanged(); + break; + case OverwriteBehavior.ask: + if (!context.mounted) return; + final shouldOverwrite = await OverwriteWarningDialog.show( + context: context, + existingFiles: conflictingItems.map((i) => i.outputPath).toList(), + ); + if (!shouldOverwrite) { + return; // User cancelled + } } } @@ -865,17 +881,18 @@ class MainWindow extends StatelessWidget { viewModel.startProcessing(); } - /// Returns list of output file paths that already exist. - Future> _getExistingOutputFiles(List queue) async { - final existingFiles = []; + /// Returns queue items (from those that will be processed) whose output + /// file already exists on disk. + Future> _getConflictingItems(List queue) async { + final conflicting = []; for (final item in queue) { // Check items that will be processed (ready, failed, completed, cancelled) if (item.canProcess || item.canReprocess) { if (await File(item.outputPath).exists()) { - existingFiles.add(item.outputPath); + conflicting.add(item); } } } - return existingFiles; + return conflicting; } } diff --git a/app/lib/views/overwrite_warning_dialog.dart b/app/lib/views/overwrite_warning_dialog.dart index 9fa35d3..aa5f6e4 100644 --- a/app/lib/views/overwrite_warning_dialog.dart +++ b/app/lib/views/overwrite_warning_dialog.dart @@ -109,6 +109,14 @@ class OverwriteWarningDialog extends StatelessWidget { }, ), ), + const SizedBox(height: 12), + Text( + 'You can change this behavior in Settings → General → ' + 'Existing Output Files.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ], ), ), diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index 292489e..734dd29 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -12,6 +12,7 @@ import '../../models/video_job.dart'; import '../../services/advanced_mode_service.dart'; import '../../services/dependency_manager.dart'; import '../../services/hardware_encoder_detector.dart'; +import '../../services/overwrite_behavior_service.dart'; import '../../services/temp_directory_service.dart'; import '../../services/update_checker.dart'; import '../../utils/pixel_format.dart'; @@ -1748,13 +1749,26 @@ class _GeneralSettingsTabState extends State<_GeneralSettingsTab> { /// [TempDirectoryService] so the row repaints as soon as it's changed. String? _tempOverride; + /// Mirrors [OverwriteBehaviorService] so the row repaints as soon as it's + /// changed. + OverwriteBehavior _overwriteBehavior = OverwriteBehavior.ask; + @override void initState() { super.initState(); _tempOverride = TempDirectoryService.instance.override; + _overwriteBehavior = OverwriteBehaviorService.instance.behavior; _loadSettings(); } + Future _setOverwriteBehavior(OverwriteBehavior? value) async { + if (value == null) return; + await OverwriteBehaviorService.instance.setBehavior(value); + if (mounted) { + setState(() => _overwriteBehavior = value); + } + } + /// Pick a directory for scratch files. Future _selectTempDirectory() async { final result = await FilePicker.platform.getDirectoryPath( @@ -1902,6 +1916,38 @@ class _GeneralSettingsTabState extends State<_GeneralSettingsTab> { const SizedBox(height: 24), + _buildSection( + context, + title: 'Existing Output Files', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final behavior in OverwriteBehavior.values) + RadioListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(behavior.label), + subtitle: Text(behavior.description), + value: behavior, + groupValue: _overwriteBehavior, + onChanged: _setOverwriteBehavior, + ), + const SizedBox(height: 4), + Text( + 'What to do when a job\'s output file already exists on disk.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + _buildSection( context, title: 'About & Feedback',