Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,13 @@ Scratch files — generated scripts, preview frames, job files, extracted DVD ti

</details>

<details>
<summary><b>Existing output files</b></summary>

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.

</details>

## 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.
Expand Down
5 changes: 5 additions & 0 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down
109 changes: 109 additions & 0 deletions app/lib/services/overwrite_behavior_service.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void> 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<String> 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++;
}
}
}
6 changes: 6 additions & 0 deletions app/lib/viewmodels/main_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
45 changes: 31 additions & 14 deletions app/lib/views/main_window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
}
}
}

Expand Down Expand Up @@ -865,17 +881,18 @@ class MainWindow extends StatelessWidget {
viewModel.startProcessing();
}

/// Returns list of output file paths that already exist.
Future<List<String>> _getExistingOutputFiles(List<QueueItem> queue) async {
final existingFiles = <String>[];
/// Returns queue items (from those that will be processed) whose output
/// file already exists on disk.
Future<List<QueueItem>> _getConflictingItems(List<QueueItem> queue) async {
final conflicting = <QueueItem>[];
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;
}
}
8 changes: 8 additions & 0 deletions app/lib/views/overwrite_warning_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
],
),
),
Expand Down
46 changes: 46 additions & 0 deletions app/lib/views/settings/settings_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> _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<void> _selectTempDirectory() async {
final result = await FilePicker.platform.getDirectoryPath(
Expand Down Expand Up @@ -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<OverwriteBehavior>(
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',
Expand Down