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