From e0d8dbc6103e51d18fdfc592c442370a8814725c Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 2 Aug 2026 00:34:06 +1000 Subject: [PATCH 1/4] feat(ui): expand filter settings inline, add resizable panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass settings used to render in a separate panel below the pass list, so the controls you were editing sat far from the filter they belonged to. Each pass now expands in place: tapping a row reveals its settings directly underneath in a bordered block, and tapping it again collapses it. `selectedPass` is nullable to allow that collapsed state. Above every filter's options is a description block — the schema's one-line `description`, with a "More" expander revealing the new `longDescription`: what the filter does and when you'd reach for it. All twelve core schemas carry one. Panels are now resizable. `ResizableSplit` puts a draggable divider between two panes (proper resize cursor, hover highlight); it is used for preview vs. right-hand panel and for queue vs. pipeline list. The dragged size persists across launches, and double-clicking a divider returns the pane to its automatic size. The queue pane's automatic size now follows its contents rather than a fixed 180px, so a single file no longer leaves a mostly-empty box; it grows per item up to 260px, after which the list scrolls. --- app/assets/filters/core/chroma_fixes.json | 1 + app/assets/filters/core/color_correction.json | 1 + app/assets/filters/core/crop_resize.json | 1 + app/assets/filters/core/deband.json | 1 + app/assets/filters/core/deblock.json | 1 + app/assets/filters/core/dehalo.json | 1 + app/assets/filters/core/deinterlace.json | 1 + app/assets/filters/core/descratch.json | 1 + app/assets/filters/core/noise_reduction.json | 1 + app/assets/filters/core/sharpen.json | 1 + app/assets/filters/core/spotless.json | 1 + app/assets/filters/core/subtitles.json | 1 + app/lib/models/filter_schema.dart | 8 +- app/lib/viewmodels/main_viewmodel.dart | 11 +- app/lib/views/main_window.dart | 133 ++++++------ app/lib/views/pass_list/pass_list_item.dart | 184 +++++++++++------ app/lib/views/pass_list/pass_list_panel.dart | 180 ++++++++-------- ...ntainer.dart => pass_settings_inline.dart} | 193 ++++++++++++++--- app/lib/views/queue_panel.dart | 7 +- app/lib/widgets/resizable_split.dart | 194 ++++++++++++++++++ 20 files changed, 659 insertions(+), 263 deletions(-) rename app/lib/views/pass_settings/{pass_settings_container.dart => pass_settings_inline.dart} (50%) create mode 100644 app/lib/widgets/resizable_split.dart diff --git a/app/assets/filters/core/chroma_fixes.json b/app/assets/filters/core/chroma_fixes.json index eb94419..cda414a 100644 --- a/app/assets/filters/core/chroma_fixes.json +++ b/app/assets/filters/core/chroma_fixes.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Chroma Fixes", "description": "Fix chroma bleeding, rainbows, and dot crawl", + "longDescription": "Repairs colour-specific damage from analog and composite video: chroma shifted sideways from the luma it belongs to, colour bleeding past edges, rainbow shimmer over fine patterns, and dots crawling along edges.\n\nUse it for VHS, Video8 and other composite captures. Each fix is enabled separately, so turn on only the ones matching what you can actually see in the preview — every one of them costs some colour detail.", "category": "cleanup", "icon": "palette", "order": 8, diff --git a/app/assets/filters/core/color_correction.json b/app/assets/filters/core/color_correction.json index 41471f8..c975995 100644 --- a/app/assets/filters/core/color_correction.json +++ b/app/assets/filters/core/color_correction.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Color Correction", "description": "Adjust brightness, contrast, saturation and levels", + "longDescription": "Adjusts brightness, contrast, saturation and the black and white levels.\n\nUse it to rescue washed-out or crushed transfers, to fix a capture made at the wrong levels (limited 16-235 read as full 0-255, or the reverse), or to lift colour from faded film. Small moves go a long way — check the before/after preview rather than judging by numbers.", "category": "color", "icon": "tune", "order": 7, diff --git a/app/assets/filters/core/crop_resize.json b/app/assets/filters/core/crop_resize.json index 17cdc0a..e122afa 100644 --- a/app/assets/filters/core/crop_resize.json +++ b/app/assets/filters/core/crop_resize.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Crop & Resize", "description": "Crop borders and resize or upscale video", + "longDescription": "Trims unwanted borders and changes the output resolution. Cropping happens first, then resizing.\n\nUse crop to cut the head-switching noise along the bottom of VHS captures and the black overscan edges of broadcast material — otherwise the encoder spends bitrate on them. Use resize for a target resolution, or the NNEDI3 upscaler for a much better 2x/4x enlargement than a plain kernel. Keep crop values even so they stay aligned with chroma subsampling.", "category": "transform", "icon": "crop", "order": 9, diff --git a/app/assets/filters/core/deband.json b/app/assets/filters/core/deband.json index 270b494..50badfd 100644 --- a/app/assets/filters/core/deband.json +++ b/app/assets/filters/core/deband.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Deband", "description": "Remove banding artifacts using f3kdb", + "longDescription": "Replaces the visible stair-steps in smooth gradients — skies, fades, studio backdrops — with a smooth ramp, adding a little grain to disguise the transition.\n\nUse it when an 8-bit source shows distinct bands, which often becomes obvious only after denoising has removed the grain that was hiding them. Too high a threshold blurs genuine low-contrast detail, so raise it gradually.", "category": "cleanup", "icon": "gradient", "order": 5, diff --git a/app/assets/filters/core/deblock.json b/app/assets/filters/core/deblock.json index 3e8bc33..73cb92a 100644 --- a/app/assets/filters/core/deblock.json +++ b/app/assets/filters/core/deblock.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Deblock", "description": "Remove blocking artifacts from compressed video", + "longDescription": "Smooths the square 8x8 block edges that low-bitrate compression leaves behind, without flattening the detail inside each block.\n\nUse it on heavily compressed MPEG sources — old DVDs, VCD, downloaded or streamed footage — especially in dark or flat areas where blocking shows most. Run it before sharpening, which would otherwise make the block edges crisper rather than softer.", "category": "cleanup", "icon": "grid_off", "order": 3, diff --git a/app/assets/filters/core/dehalo.json b/app/assets/filters/core/dehalo.json index 182f5ba..c91c8e7 100644 --- a/app/assets/filters/core/dehalo.json +++ b/app/assets/filters/core/dehalo.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Dehalo", "description": "Remove halo artifacts around edges", + "longDescription": "Removes the bright outline that sits alongside high-contrast edges — the ringing left by over-sharpening, upscaling or heavy compression.\n\nUse it when edges look traced with a light pen: common on VHS run through a sharpening time-base corrector, and on upscaled or hard-compressed material. Apply it after deinterlacing and denoising. Too much strength eats the fine detail right next to the edge.", "category": "cleanup", "icon": "blur_off", "order": 4, diff --git a/app/assets/filters/core/deinterlace.json b/app/assets/filters/core/deinterlace.json index 1a768f4..7f9fda8 100644 --- a/app/assets/filters/core/deinterlace.json +++ b/app/assets/filters/core/deinterlace.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Deinterlace", "description": "QTGMC deinterlacing or IVTC inverse telecine", + "longDescription": "Turns interlaced video — where each frame holds two half-height fields captured at different moments — into whole progressive frames, or removes the pulldown pattern from film that was telecined to video. QTGMC rebuilds every field into a full frame for the smoothest motion; IVTC instead recovers the original film frames and drops the duplicates.\n\nUse it on anything from tape or broadcast — VHS, Video8, DV, DVD — where moving edges show comb teeth. Leave it off for footage that is already progressive. Run it first: every other filter works better on whole frames.", "category": "deinterlace", "icon": "layers", "order": 1, diff --git a/app/assets/filters/core/descratch.json b/app/assets/filters/core/descratch.json index 228c86a..8a8f17c 100644 --- a/app/assets/filters/core/descratch.json +++ b/app/assets/filters/core/descratch.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "DeScratch", "description": "Remove vertical scratches from scanned film", + "longDescription": "Finds narrow vertical lines that run down the picture — the bright or dark scratches left by grit in a projector or scanner gate — and fills them in from the pixels either side.\n\nUse it on scanned film and telecine transfers. It has nothing to fix on tape sources, and genuine thin vertical detail (railings, masts, poles) can be mistaken for a scratch, so check the preview before committing.", "category": "cleanup", "icon": "healing", "order": 2, diff --git a/app/assets/filters/core/noise_reduction.json b/app/assets/filters/core/noise_reduction.json index c3b22f5..a4818c4 100644 --- a/app/assets/filters/core/noise_reduction.json +++ b/app/assets/filters/core/noise_reduction.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Noise Reduction", "description": "Remove video noise and grain", + "longDescription": "Reduces grain, analog noise and colour speckle while trying to keep real detail. Temporal denoisers average across neighbouring frames and are best for steady grain; spatial ones smooth within a single frame and cope better with motion.\n\nUse it on VHS, Hi8 and other noisy captures, and before sharpening or encoding — clean footage compresses far better at the same bitrate. Too much strength smears motion and flattens texture into plastic.", "category": "cleanup", "icon": "grain", "order": 2, diff --git a/app/assets/filters/core/sharpen.json b/app/assets/filters/core/sharpen.json index 496a765..1bbd9b6 100644 --- a/app/assets/filters/core/sharpen.json +++ b/app/assets/filters/core/sharpen.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Sharpen", "description": "Sharpen video edges and detail", + "longDescription": "Raises contrast right at edges so a soft picture reads as crisper. It cannot add detail that was never captured — it only makes what is there more visible.\n\nUse it sparingly on soft tape and DVD sources, and always after deinterlacing and denoising: sharpening first amplifies noise and creates halos. If edges start to glow, back off or pair it with Dehalo.", "category": "enhancement", "icon": "auto_fix_high", "order": 6, diff --git a/app/assets/filters/core/spotless.json b/app/assets/filters/core/spotless.json index f8d9d5b..33d76ad 100644 --- a/app/assets/filters/core/spotless.json +++ b/app/assets/filters/core/spotless.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "SpotLess", "description": "Remove dust, dirt, and temporal spots from film", + "longDescription": "Removes single-frame blemishes: dust specks, hairs and emulsion flecks that flash up for one frame and vanish. It compares each frame against its neighbours and replaces anything that is present in only one of them.\n\nUse it on scanned or telecined film, after deinterlacing. Because the test is temporal, fast or erratic motion can be read as a spot — if you see smearing or ghosting on movement, ease off the strength.", "category": "cleanup", "icon": "auto_fix_high", "order": 3, diff --git a/app/assets/filters/core/subtitles.json b/app/assets/filters/core/subtitles.json index 0dfa089..07e53a8 100644 --- a/app/assets/filters/core/subtitles.json +++ b/app/assets/filters/core/subtitles.json @@ -4,6 +4,7 @@ "version": "1.0.0", "name": "Subtitles", "description": "Generate subtitles from speech using Whisper AI", + "longDescription": "Transcribes the spoken audio with the Whisper speech-recognition model and writes it out as a subtitle track alongside the video.\n\nUse it to caption footage that has no subtitles of its own — home video, interviews, lectures. Larger models are more accurate but considerably slower, and accuracy falls away with heavy background noise or overlapping speakers. This pass runs after the encode and never alters the picture.", "category": "enhancement", "icon": "subtitles", "order": 100, diff --git a/app/lib/models/filter_schema.dart b/app/lib/models/filter_schema.dart index 6f13c5d..75e7be8 100644 --- a/app/lib/models/filter_schema.dart +++ b/app/lib/models/filter_schema.dart @@ -345,9 +345,14 @@ class FilterSchema { /// Display name. final String name; - /// Description of what this filter does. + /// Short one-line description of what this filter does. final String? description; + /// Longer prose shown above the filter's options: what the filter does and + /// when you'd reach for it. Paragraphs are separated by a blank line. + /// Falls back to [description] in the UI when absent. + final String? longDescription; + /// Category for grouping (e.g., "cleanup", "enhancement"). final String? category; @@ -394,6 +399,7 @@ class FilterSchema { required this.version, required this.name, this.description, + this.longDescription, this.category, this.icon, this.order = 0, diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index 6882f48..6169c82 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -49,7 +49,9 @@ class MainViewModel extends ChangeNotifier { // Processing pipeline ProcessingPipeline _processingPipeline = const ProcessingPipeline(); - PassType _selectedPass = PassType.deinterlace; + /// Pass whose settings are expanded inline in the pass list, or null when + /// every pass is collapsed. + PassType? _selectedPass = PassType.deinterlace; bool _advancedMode = false; // Scan-type auto-configuration of the deinterlace pipeline runs ONCE, for the @@ -120,7 +122,7 @@ class MainViewModel extends ChangeNotifier { bool get autoFieldOrder => _autoFieldOrder; FieldOrder get manualFieldOrder => _manualFieldOrder; ProcessingPipeline get processingPipeline => _processingPipeline; - PassType get selectedPass => _selectedPass; + PassType? get selectedPass => _selectedPass; bool get advancedMode => _advancedMode; /// Whether a usable OpenCL device was detected (gates the QTGMC OpenCL @@ -1032,9 +1034,10 @@ class MainViewModel extends ChangeNotifier { } } - /// Selects a pass for editing. + /// Expands a pass's settings inline, collapsing whichever was open. Selecting + /// the pass that is already expanded collapses it. void selectPass(PassType pass) { - _selectedPass = pass; + _selectedPass = _selectedPass == pass ? null : pass; notifyListeners(); } diff --git a/app/lib/views/main_window.dart b/app/lib/views/main_window.dart index 10c72a9..8c6181c 100644 --- a/app/lib/views/main_window.dart +++ b/app/lib/views/main_window.dart @@ -17,11 +17,11 @@ import 'drop_zone.dart'; import 'dvd_title_picker.dart'; import 'overwrite_warning_dialog.dart'; import 'pass_list/pass_list_panel.dart'; -import 'pass_settings/pass_settings_container.dart'; import 'preview_panel.dart'; import 'progress_panel.dart'; import 'queue_panel.dart'; import 'settings/settings_dialog.dart'; +import '../widgets/resizable_split.dart'; class MainWindow extends StatelessWidget { const MainWindow({super.key}); @@ -283,83 +283,82 @@ class MainWindow extends StatelessWidget { return const ProgressPanel(); } - return Row( - children: [ - // Preview panel (left side) - const Expanded( - flex: 3, - child: PreviewPanel(), - ), - - // Divider - VerticalDivider( - width: 1, - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), - ), - - // Info panel (right side) - Expanded( - flex: 2, - child: _buildInfoPanel(context, viewModel), - ), - ], + // Preview | info panel, with a divider the user can drag. Before it's + // dragged the preview takes 60% of the width, as the old 3:2 flex did. + return LayoutBuilder( + builder: (context, constraints) { + return ResizableSplit( + axis: Axis.horizontal, + storageKey: 'main.preview', + initialFirstSize: constraints.maxWidth * 0.6, + minFirstSize: 320, + minSecondSize: 320, + first: const PreviewPanel(), + second: _buildInfoPanel(context, viewModel), + ); + }, ); } - Widget _buildInfoPanel(BuildContext context, MainViewModel viewModel) { - return Column( - children: [ - // Queue panel at top - SizedBox( - height: 180, - child: const QueuePanel(), - ), + // Queue sizing. The queue starts just tall enough for the files actually in + // it — one file shouldn't leave a mostly-empty panel — and stops growing at + // [_queueMaxAutoHeight], after which the list scrolls. Dragging the divider + // overrides this; double-clicking it hands the queue back to auto-sizing. + static const double _queueHeaderHeight = 37; + static const double _queueRowHeight = 54; + static const double _queueMinHeight = 72; + static const double _queueMaxAutoHeight = 260; + + double _queueAutoHeight(int itemCount) { + final wanted = _queueHeaderHeight + _queueRowHeight * itemCount; + return wanted.clamp(_queueMinHeight, _queueMaxAutoHeight); + } - // Output settings row - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + Widget _buildInfoPanel(BuildContext context, MainViewModel viewModel) { + return ResizableSplit( + axis: Axis.vertical, + storageKey: 'main.queue', + initialFirstSize: _queueAutoHeight(viewModel.queue.length), + minFirstSize: _queueMinHeight, + minSecondSize: 200, + first: const QueuePanel(), + second: Column( + children: [ + // Output settings row + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), ), ), - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Output: ${viewModel.encodingSettings.codec.displayName} → ${viewModel.encodingSettings.container.name.toUpperCase()}', - style: Theme.of(context).textTheme.bodySmall, + child: Row( + children: [ + Expanded( + child: Text( + 'Output: ${viewModel.encodingSettings.codec.displayName} → ${viewModel.encodingSettings.container.name.toUpperCase()}', + style: Theme.of(context).textTheme.bodySmall, + ), ), - ), - TextButton( - onPressed: () => _showSettings(context, viewModel), - child: const Text('Edit'), - ), - ], + TextButton( + onPressed: () => _showSettings(context, viewModel), + child: const Text('Edit'), + ), + ], + ), ), - ), - // Scrollable pass list and settings - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Pass list panel - const PassListPanel(), - - const Divider(height: 32), - - // Selected pass settings - const PassSettingsContainer(), - ], + // Scrollable pass list — each pass expands inline to show its settings + const Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.all(16), + child: PassListPanel(), ), ), - ), - ], + ], + ), ); } diff --git a/app/lib/views/pass_list/pass_list_item.dart b/app/lib/views/pass_list/pass_list_item.dart index aca91da..07579bc 100644 --- a/app/lib/views/pass_list/pass_list_item.dart +++ b/app/lib/views/pass_list/pass_list_item.dart @@ -3,24 +3,32 @@ import 'package:flutter/material.dart'; import '../../models/processing_pipeline.dart'; /// A single item in the pass list showing a processing pass. +/// +/// Tapping the row expands it in place: [expandedChild] (the pass's settings) +/// renders directly underneath the row rather than in a separate panel, so the +/// options stay visually attached to the filter they belong to. class PassListItem extends StatelessWidget { final PassType passType; final String title; final String subtitle; final bool isEnabled; - final bool isSelected; + final bool isExpanded; final ValueChanged onToggle; final VoidCallback onTap; + /// Settings shown inline while expanded. Only built for the expanded item. + final Widget? expandedChild; + const PassListItem({ super.key, required this.passType, required this.title, required this.subtitle, required this.isEnabled, - required this.isSelected, + required this.isExpanded, required this.onToggle, required this.onTap, + this.expandedChild, }); @override @@ -28,79 +36,125 @@ class PassListItem extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), + padding: EdgeInsets.symmetric(vertical: isExpanded ? 6 : 2), child: Material( - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.5) + color: isExpanded + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.35) : Colors.transparent, borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - children: [ - // Checkbox for enable/disable - SizedBox( - width: 24, - height: 24, - child: Checkbox( - value: isEnabled, - onChanged: (value) => onToggle(value ?? false), - visualDensity: VisualDensity.compact, - ), - ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isExpanded + ? colorScheme.primary.withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildRow(context, colorScheme), + + // Inline settings — animates open/closed. + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: isExpanded && expandedChild != null + ? Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Divider( + height: 12, + color: colorScheme.outline.withValues(alpha: 0.2), + ), + expandedChild!, + ], + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ), + ), + ); + } - const SizedBox(width: 8), + Widget _buildRow(BuildContext context, ColorScheme colorScheme) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + // Checkbox for enable/disable + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: isEnabled, + onChanged: (value) => onToggle(value ?? false), + visualDensity: VisualDensity.compact, + ), + ), - // Pass icon - Icon( - _getIconForPass(passType), - size: 20, - color: isEnabled - ? colorScheme.primary - : colorScheme.onSurface.withValues(alpha: 0.4), - ), + const SizedBox(width: 8), - const SizedBox(width: 12), + // Pass icon + Icon( + _getIconForPass(passType), + size: 20, + color: isEnabled + ? colorScheme.primary + : colorScheme.onSurface.withValues(alpha: 0.4), + ), - // Title and subtitle - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - color: isEnabled - ? colorScheme.onSurface - : colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - Text( - subtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: isEnabled - ? colorScheme.onSurface.withValues(alpha: 0.7) - : colorScheme.onSurface.withValues(alpha: 0.4), - ), - ), - ], - ), - ), + const SizedBox(width: 12), - // Arrow indicator if selected - if (isSelected) - Icon( - Icons.chevron_right, - size: 20, - color: colorScheme.primary, + // Title and subtitle + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isEnabled + ? colorScheme.onSurface.withValues(alpha: 0.7) + : colorScheme.onSurface.withValues(alpha: 0.4), + ), ), - ], + ], + ), ), - ), + + // Expand/collapse affordance + AnimatedRotation( + turns: isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 180), + child: Icon( + Icons.expand_more, + size: 20, + color: isExpanded + ? colorScheme.primary + : colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], ), ), ); diff --git a/app/lib/views/pass_list/pass_list_panel.dart b/app/lib/views/pass_list/pass_list_panel.dart index 676316a..a77be6f 100644 --- a/app/lib/views/pass_list/pass_list_panel.dart +++ b/app/lib/views/pass_list/pass_list_panel.dart @@ -4,10 +4,14 @@ import 'package:provider/provider.dart'; import '../../models/processing_pipeline.dart'; import '../../services/whisper_addon_manager.dart'; import '../../viewmodels/main_viewmodel.dart'; +import '../pass_settings/pass_settings_inline.dart'; import '../whisper_download_dialog.dart'; import 'pass_list_item.dart'; /// Panel showing the list of processing passes that can be enabled/disabled. +/// +/// Each pass expands in place to reveal its settings — only one at a time, and +/// only the expanded pass's settings are built. class PassListPanel extends StatelessWidget { const PassListPanel({super.key}); @@ -17,6 +21,27 @@ class PassListPanel extends StatelessWidget { builder: (context, viewModel, child) { final pipeline = viewModel.processingPipeline; + /// Builds one row, wiring up expansion and the inline settings. + Widget item( + PassType passType, + String title, + String subtitle, + bool isEnabled, { + ValueChanged? onToggle, + }) { + final isExpanded = viewModel.selectedPass == passType; + return PassListItem( + passType: passType, + title: title, + subtitle: subtitle, + isEnabled: isEnabled, + isExpanded: isExpanded, + onToggle: onToggle ?? (enabled) => viewModel.togglePass(passType, enabled), + onTap: () => viewModel.selectPass(passType), + expandedChild: isExpanded ? PassSettingsInline(passType: passType) : null, + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -30,114 +55,81 @@ class PassListPanel extends StatelessWidget { const SizedBox(height: 8), // List of passes - PassListItem( - passType: PassType.deinterlace, - title: 'Deinterlace', - subtitle: _getDeinterlaceSummary(pipeline), - isEnabled: pipeline.deinterlace.enabled, - isSelected: viewModel.selectedPass == PassType.deinterlace, - onToggle: (enabled) => viewModel.togglePass(PassType.deinterlace, enabled), - onTap: () => viewModel.selectPass(PassType.deinterlace), + item( + PassType.deinterlace, + 'Deinterlace', + _getDeinterlaceSummary(pipeline), + pipeline.deinterlace.enabled, ), - PassListItem( - passType: PassType.descratch, - title: 'DeScratch', - subtitle: pipeline.descratch.summary, - isEnabled: pipeline.descratch.enabled, - isSelected: viewModel.selectedPass == PassType.descratch, - onToggle: (enabled) => viewModel.togglePass(PassType.descratch, enabled), - onTap: () => viewModel.selectPass(PassType.descratch), + item( + PassType.descratch, + 'DeScratch', + pipeline.descratch.summary, + pipeline.descratch.enabled, ), - PassListItem( - passType: PassType.spotless, - title: 'SpotLess', - subtitle: pipeline.spotless.summary, - isEnabled: pipeline.spotless.enabled, - isSelected: viewModel.selectedPass == PassType.spotless, - onToggle: (enabled) => viewModel.togglePass(PassType.spotless, enabled), - onTap: () => viewModel.selectPass(PassType.spotless), + item( + PassType.spotless, + 'SpotLess', + pipeline.spotless.summary, + pipeline.spotless.enabled, ), - PassListItem( - passType: PassType.noiseReduction, - title: 'Noise Reduction', - subtitle: pipeline.noiseReduction.summary, - isEnabled: pipeline.noiseReduction.enabled, - isSelected: viewModel.selectedPass == PassType.noiseReduction, - onToggle: (enabled) => viewModel.togglePass(PassType.noiseReduction, enabled), - onTap: () => viewModel.selectPass(PassType.noiseReduction), + item( + PassType.noiseReduction, + 'Noise Reduction', + pipeline.noiseReduction.summary, + pipeline.noiseReduction.enabled, ), - PassListItem( - passType: PassType.dehalo, - title: 'Dehalo', - subtitle: pipeline.dehalo.summary, - isEnabled: pipeline.dehalo.enabled, - isSelected: viewModel.selectedPass == PassType.dehalo, - onToggle: (enabled) => viewModel.togglePass(PassType.dehalo, enabled), - onTap: () => viewModel.selectPass(PassType.dehalo), + item( + PassType.dehalo, + 'Dehalo', + pipeline.dehalo.summary, + pipeline.dehalo.enabled, ), - PassListItem( - passType: PassType.deblock, - title: 'Deblock', - subtitle: pipeline.deblock.summary, - isEnabled: pipeline.deblock.enabled, - isSelected: viewModel.selectedPass == PassType.deblock, - onToggle: (enabled) => viewModel.togglePass(PassType.deblock, enabled), - onTap: () => viewModel.selectPass(PassType.deblock), + item( + PassType.deblock, + 'Deblock', + pipeline.deblock.summary, + pipeline.deblock.enabled, ), - PassListItem( - passType: PassType.deband, - title: 'Deband', - subtitle: pipeline.deband.summary, - isEnabled: pipeline.deband.enabled, - isSelected: viewModel.selectedPass == PassType.deband, - onToggle: (enabled) => viewModel.togglePass(PassType.deband, enabled), - onTap: () => viewModel.selectPass(PassType.deband), + item( + PassType.deband, + 'Deband', + pipeline.deband.summary, + pipeline.deband.enabled, ), - PassListItem( - passType: PassType.sharpen, - title: 'Sharpen', - subtitle: pipeline.sharpen.summary, - isEnabled: pipeline.sharpen.enabled, - isSelected: viewModel.selectedPass == PassType.sharpen, - onToggle: (enabled) => viewModel.togglePass(PassType.sharpen, enabled), - onTap: () => viewModel.selectPass(PassType.sharpen), + item( + PassType.sharpen, + 'Sharpen', + pipeline.sharpen.summary, + pipeline.sharpen.enabled, ), - PassListItem( - passType: PassType.chromaFixes, - title: 'Chroma Fixes', - subtitle: pipeline.chromaFixes.summary, - isEnabled: pipeline.chromaFixes.enabled, - isSelected: viewModel.selectedPass == PassType.chromaFixes, - onToggle: (enabled) => viewModel.togglePass(PassType.chromaFixes, enabled), - onTap: () => viewModel.selectPass(PassType.chromaFixes), + item( + PassType.chromaFixes, + 'Chroma Fixes', + pipeline.chromaFixes.summary, + pipeline.chromaFixes.enabled, ), - PassListItem( - passType: PassType.colorCorrection, - title: 'Color Correction', - subtitle: pipeline.colorCorrection.summary, - isEnabled: pipeline.colorCorrection.enabled, - isSelected: viewModel.selectedPass == PassType.colorCorrection, - onToggle: (enabled) => viewModel.togglePass(PassType.colorCorrection, enabled), - onTap: () => viewModel.selectPass(PassType.colorCorrection), + item( + PassType.colorCorrection, + 'Color Correction', + pipeline.colorCorrection.summary, + pipeline.colorCorrection.enabled, ), - PassListItem( - passType: PassType.cropResize, - title: 'Crop / Resize', - subtitle: pipeline.cropResize.summary, - isEnabled: pipeline.cropResize.enabled, - isSelected: viewModel.selectedPass == PassType.cropResize, - onToggle: (enabled) => viewModel.togglePass(PassType.cropResize, enabled), - onTap: () => viewModel.selectPass(PassType.cropResize), + item( + PassType.cropResize, + 'Crop / Resize', + pipeline.cropResize.summary, + pipeline.cropResize.enabled, ), // Post-Processing section @@ -152,14 +144,12 @@ class PassListPanel extends StatelessWidget { ), ), - PassListItem( - passType: PassType.subtitles, - title: 'Subtitles', - subtitle: pipeline.subtitles.summary, - isEnabled: pipeline.subtitles.enabled, - isSelected: viewModel.selectedPass == PassType.subtitles, + item( + PassType.subtitles, + 'Subtitles', + pipeline.subtitles.summary, + pipeline.subtitles.enabled, onToggle: (enabled) => _handleSubtitlesToggle(context, viewModel, enabled), - onTap: () => viewModel.selectPass(PassType.subtitles), ), const SizedBox(height: 16), diff --git a/app/lib/views/pass_settings/pass_settings_container.dart b/app/lib/views/pass_settings/pass_settings_inline.dart similarity index 50% rename from app/lib/views/pass_settings/pass_settings_container.dart rename to app/lib/views/pass_settings/pass_settings_inline.dart index 4ff9d9c..4fc3cfe 100644 --- a/app/lib/views/pass_settings/pass_settings_container.dart +++ b/app/lib/views/pass_settings/pass_settings_inline.dart @@ -12,11 +12,16 @@ import '../../widgets/warning_banner.dart'; import '../settings/dynamic_filter_panel.dart'; import '../whisper_download_dialog.dart'; -/// Container widget that shows the settings panel for the currently selected pass. +/// The settings for one processing pass, rendered inline underneath that pass's +/// row in the pass list. Leads with the filter's description — what it does and +/// when to use it — followed by any warnings and then the generated controls. /// /// Uses schema-driven UI generation from FilterRegistry. -class PassSettingsContainer extends StatelessWidget { - const PassSettingsContainer({super.key}); +class PassSettingsInline extends StatelessWidget { + /// The pass whose settings to show. + final PassType passType; + + const PassSettingsInline({super.key, required this.passType}); /// Maps PassType to filter schema ID. static String _getFilterId(PassType passType) { @@ -52,7 +57,6 @@ class PassSettingsContainer extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, viewModel, child) { - final passType = viewModel.selectedPass; final filterId = _getFilterId(passType); final schema = FilterRegistry.instance.get(filterId); @@ -64,30 +68,32 @@ class PassSettingsContainer extends StatelessWidget { // Use cached dynamic params from ViewModel (preserves null values) final params = viewModel.getDynamicParams(filterId); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: SingleChildScrollView( - key: ValueKey(filterId), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildOpenCLWarning(context, viewModel, filterId, params), - _buildBitDepthWarning(context, viewModel, schema, params), - DynamicFilterPanelCompact( - schema: schema, - params: params, - onChanged: (newParams) { - _handleParamChange(context, viewModel, filterId, params, newParams); - }, - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDescription(context, schema), + _buildOpenCLWarning(context, viewModel, filterId, params), + _buildBitDepthWarning(context, viewModel, schema, params), + DynamicFilterPanelCompact( + schema: schema, + params: params, + onChanged: (newParams) { + _handleParamChange(context, viewModel, filterId, params, newParams); + }, ), - ), + ], ); }, ); } + /// What the filter does, shown above every option: the one-line summary + /// always, with the fuller "what it does and when to use it" prose behind an + /// expander. See [_FilterDescription]. + Widget _buildDescription(BuildContext context, FilterSchema schema) { + return _FilterDescription(schema: schema); + } + /// Warning shown when the deinterlace pass uses an OpenCL-only option that /// won't run on this machine: the "knlmeanscl" denoiser (gated on the /// knlmeanscl-specific probe) or the QTGMC OpenCL toggle (gated on the @@ -172,21 +178,21 @@ class PassSettingsContainer extends StatelessWidget { } Widget _buildFallbackPanel(BuildContext context, PassType passType) { - return Center( + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.warning_amber_rounded, - size: 48, + size: 32, color: Theme.of(context).colorScheme.error, ), - const SizedBox(height: 16), + const SizedBox(height: 12), Text( 'Filter schema not found', - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 8), + const SizedBox(height: 4), Text( 'Could not load settings for ${passType.displayName}', style: Theme.of(context).textTheme.bodySmall, @@ -196,3 +202,134 @@ class PassSettingsContainer extends StatelessWidget { ); } } + +/// The blurb above a filter's options: the schema's one-line `description`, +/// with the fuller `longDescription` — what the filter does and when to reach +/// for it — behind an expander so it doesn't push the controls off screen. +/// +/// Collapsed by default, and reset each time a pass is expanded, since the +/// widget only exists while its pass is open. +class _FilterDescription extends StatefulWidget { + final FilterSchema schema; + + const _FilterDescription({required this.schema}); + + @override + State<_FilterDescription> createState() => _FilterDescriptionState(); +} + +class _FilterDescriptionState extends State<_FilterDescription> { + bool _showDetail = false; + + @override + Widget build(BuildContext context) { + final schema = widget.schema; + final summary = schema.description?.trim(); + final detail = schema.longDescription?.trim(); + + // Nothing to say at all. + if ((summary == null || summary.isEmpty) && (detail == null || detail.isEmpty)) { + return const SizedBox.shrink(); + } + + // With no summary the long text is all we have, so show it outright. + final headline = (summary != null && summary.isNotEmpty) ? summary : detail!; + final hasDetail = + detail != null && detail.isNotEmpty && detail != headline; + + final colorScheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(6), + border: Border( + left: BorderSide(color: colorScheme.primary.withValues(alpha: 0.5), width: 3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeadline(context, headline, hasDetail), + + // Full description — animates open below the summary. + AnimatedSize( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _showDetail && hasDetail + ? Padding( + padding: const EdgeInsets.only(top: 8, right: 4), + child: _buildParagraphs(context, detail), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } + + Widget _buildHeadline(BuildContext context, String headline, bool hasDetail) { + final colorScheme = Theme.of(context).colorScheme; + final text = Text( + headline, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.8), + height: 1.3, + ), + ); + + if (!hasDetail) return Padding(padding: const EdgeInsets.only(right: 4), child: text); + + return InkWell( + onTap: () => setState(() => _showDetail = !_showDetail), + borderRadius: BorderRadius.circular(4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: text), + const SizedBox(width: 4), + Text( + _showDetail ? 'Less' : 'More', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + ), + ), + AnimatedRotation( + turns: _showDetail ? 0.5 : 0, + duration: const Duration(milliseconds: 160), + child: Icon(Icons.expand_more, size: 18, color: colorScheme.primary), + ), + ], + ), + ); + } + + /// Renders the detail text, treating blank lines as paragraph breaks. + Widget _buildParagraphs(BuildContext context, String detail) { + final colorScheme = Theme.of(context).colorScheme; + final paragraphs = detail + .split(RegExp(r'\n\s*\n')) + .map((p) => p.trim()) + .where((p) => p.isNotEmpty) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < paragraphs.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + Text( + paragraphs[i], + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + height: 1.4, + ), + ), + ], + ], + ); + } +} diff --git a/app/lib/views/queue_panel.dart b/app/lib/views/queue_panel.dart index e341dbe..5ee5a73 100644 --- a/app/lib/views/queue_panel.dart +++ b/app/lib/views/queue_panel.dart @@ -47,11 +47,12 @@ class _QueuePanelState extends State { color: _isDragging ? colorScheme.primary.withValues(alpha: 0.1) : null, + // The resizable divider below the queue draws the separating + // line, so the border only shows while a drag-and-drop is in + // flight (transparent otherwise to keep the height stable). border: Border( bottom: BorderSide( - color: _isDragging - ? colorScheme.primary - : colorScheme.outline.withValues(alpha: 0.2), + color: _isDragging ? colorScheme.primary : Colors.transparent, width: _isDragging ? 2 : 1, ), ), diff --git a/app/lib/widgets/resizable_split.dart b/app/lib/widgets/resizable_split.dart new file mode 100644 index 0000000..9813c5d --- /dev/null +++ b/app/lib/widgets/resizable_split.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A two-pane layout with a divider the user can drag to resize the panes. +/// +/// The first pane is sized in pixels; the second takes whatever is left. Until +/// the user drags the divider, the first pane follows [initialFirstSize], which +/// the parent may recompute on every build to size the pane to its content (see +/// the queue panel in `main_window.dart`). Once dragged, the user's size wins +/// and is kept — persisted across launches when [storageKey] is set. +/// +/// Sizes are always clamped so both panes keep at least their minimum, which +/// means a window resize can shrink the first pane below the user's chosen size +/// without discarding it — widen the window again and it comes back. +class ResizableSplit extends StatefulWidget { + /// [Axis.horizontal] puts the panes side by side with a vertical divider; + /// [Axis.vertical] stacks them with a horizontal divider. + final Axis axis; + + /// Leading pane — left (horizontal) or top (vertical). This is the sized one. + final Widget first; + + /// Trailing pane, which fills the remaining space. + final Widget second; + + /// Size of [first] before the user has dragged the divider. + final double initialFirstSize; + + /// Smallest size [first] may be dragged to. + final double minFirstSize; + + /// Space always left for [second]. + final double minSecondSize; + + /// When set, the dragged size is saved under this key and restored on launch. + final String? storageKey; + + const ResizableSplit({ + super.key, + required this.axis, + required this.first, + required this.second, + required this.initialFirstSize, + this.minFirstSize = 120, + this.minSecondSize = 120, + this.storageKey, + }); + + @override + State createState() => _ResizableSplitState(); +} + +class _ResizableSplitState extends State { + static const double _handleThickness = 8; + + /// Size the user dragged to, or null while the pane still auto-sizes. + double? _userSize; + + /// Size actually used by the last build — the base for drag deltas, so the + /// handle tracks the pointer even when clamping moved the pane. + double _resolvedSize = 0; + + bool _isDragging = false; + bool _isHovering = false; + + @override + void initState() { + super.initState(); + _restoreSize(); + } + + Future _restoreSize() async { + final key = widget.storageKey; + if (key == null) return; + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getDouble(_prefsKey(key)); + if (stored != null && mounted) { + setState(() => _userSize = stored); + } + } catch (_) { + // Persisted layout is a nicety — fall back to the auto size. + } + } + + Future _persistSize(double size) async { + final key = widget.storageKey; + if (key == null) return; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_prefsKey(key), size); + } catch (_) { + // Ignore — losing the saved size is harmless. + } + } + + static String _prefsKey(String key) => 'split.$key'; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final total = widget.axis == Axis.horizontal + ? constraints.maxWidth + : constraints.maxHeight; + final available = (total - _handleThickness).clamp(0.0, double.infinity); + + // Both minimums may not fit in a small window; the first pane's minimum + // yields so the second pane keeps its share. + final lower = widget.minFirstSize.clamp(0.0, available); + final upper = (available - widget.minSecondSize).clamp(lower, available); + _resolvedSize = (_userSize ?? widget.initialFirstSize).clamp(lower, upper); + + return Flex( + direction: widget.axis, + children: [ + widget.axis == Axis.horizontal + ? SizedBox(width: _resolvedSize, child: widget.first) + : SizedBox(height: _resolvedSize, child: widget.first), + _buildHandle(context, canResize: upper > lower), + Expanded(child: widget.second), + ], + ); + }, + ); + } + + Widget _buildHandle(BuildContext context, {required bool canResize}) { + final colorScheme = Theme.of(context).colorScheme; + final horizontal = widget.axis == Axis.horizontal; + final active = _isDragging || _isHovering; + + // A thin line the user sees, inside a thicker invisible strip they can grab. + final line = Container( + width: horizontal ? (active ? 2 : 1) : double.infinity, + height: horizontal ? double.infinity : (active ? 2 : 1), + color: active + ? colorScheme.primary + : colorScheme.outline.withValues(alpha: 0.2), + ); + + final handle = SizedBox( + width: horizontal ? _handleThickness : double.infinity, + height: horizontal ? double.infinity : _handleThickness, + child: Center(child: line), + ); + + if (!canResize) return handle; + + return MouseRegion( + cursor: horizontal + ? SystemMouseCursors.resizeColumn + : SystemMouseCursors.resizeRow, + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: + horizontal ? (_) => setState(() => _isDragging = true) : null, + onHorizontalDragUpdate: + horizontal ? (details) => _drag(details.delta.dx) : null, + onHorizontalDragEnd: horizontal ? (_) => _endDrag() : null, + onVerticalDragStart: + horizontal ? null : (_) => setState(() => _isDragging = true), + onVerticalDragUpdate: + horizontal ? null : (details) => _drag(details.delta.dy), + onVerticalDragEnd: horizontal ? null : (_) => _endDrag(), + onDoubleTap: _resetSize, + child: handle, + ), + ); + } + + void _drag(double delta) { + setState(() => _userSize = _resolvedSize + delta); + } + + void _endDrag() { + setState(() => _isDragging = false); + // Persist the clamped size, not the raw drag total, so a drag past the end + // stop isn't stored. + _persistSize(_resolvedSize); + } + + /// Double-clicking the divider gives the pane back to its automatic size. + void _resetSize() { + setState(() => _userSize = null); + final key = widget.storageKey; + if (key == null) return; + SharedPreferences.getInstance() + .then((prefs) => prefs.remove(_prefsKey(key))) + .catchError((_) => false); + } +} From a27bf987344ba41834ce3b78f7b6c12fbee208ed Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 2 Aug 2026 00:39:36 +1000 Subject: [PATCH 2/4] fix(macos): make the window opaque so the title bar isn't see-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WindowOptions carried backgroundColor: Colors.transparent, which has window_manager set the native window to NSColor.clear with isOpaque false. The Flutter view only covers the content area, so nothing paints behind the macOS title bar — it renders as a floating title and traffic lights over whatever is behind the window. Dropping the option restores the platform default background, which follows light/dark mode. Present since the Flutter migration; visible on macOS 26. --- app/lib/main.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index d99d1bd..997d409 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -23,11 +23,14 @@ void main() async { // Initialize window manager for desktop await windowManager.ensureInitialized(); + // No backgroundColor: a transparent one makes window_manager set the native + // window non-opaque, and since the Flutter view only covers the content area + // that leaves the macOS title bar see-through — a floating title and traffic + // lights over the desktop. The platform default follows light/dark mode. const windowOptions = WindowOptions( size: Size(900, 700), minimumSize: Size(700, 550), center: true, - backgroundColor: Colors.transparent, skipTaskbar: false, titleBarStyle: TitleBarStyle.normal, title: 'VapourBox', From c992872981aaff2261c255506b84d51b7cfeff4e Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 2 Aug 2026 00:48:39 +1000 Subject: [PATCH 3/4] feat(ui): add bug reporting, move About into Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar had accumulated an icon per destination. About moves into Settings alongside a new "Report a bug or give feedback" entry that opens the project's GitHub issue tracker, both under an "About & Feedback" section on the General tab. The toolbar keeps only the controls that act on the current job. The issues URL is built from the githubRepo in deps-version.json — the same source the About dialog uses — so a fork points at its own tracker, falling back to the upstream repo if that read fails. If no browser handler is available it offers the URL to copy rather than doing nothing. Settings tabs are reordered General, Output, Input, so the tab that opens first is the one holding app-wide settings rather than per-job input options. --- app/lib/views/main_window.dart | 17 +---- app/lib/views/settings/settings_dialog.dart | 69 +++++++++++++++++++-- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/app/lib/views/main_window.dart b/app/lib/views/main_window.dart index 8c6181c..283e6ce 100644 --- a/app/lib/views/main_window.dart +++ b/app/lib/views/main_window.dart @@ -11,7 +11,6 @@ import '../services/audio_compatibility_service.dart'; import '../services/preset_service.dart'; import '../viewmodels/main_viewmodel.dart'; import '../services/disc_detector.dart'; -import 'about_dialog.dart' as about; import 'audio_compatibility_dialog.dart'; import 'drop_zone.dart'; import 'dvd_title_picker.dart'; @@ -217,14 +216,7 @@ class MainWindow extends StatelessWidget { : () => _openDvd(context, viewModel), ), - // About button - IconButton( - icon: const Icon(Icons.info_outline), - tooltip: 'About', - onPressed: () => _showAbout(context), - ), - - // Settings button + // Settings button — About and bug reporting live in its General tab IconButton( icon: const Icon(Icons.settings), tooltip: 'Settings', @@ -669,13 +661,6 @@ class MainWindow extends StatelessWidget { } } - void _showAbout(BuildContext context) { - showDialog( - context: context, - builder: (context) => const about.AboutDialog(), - ); - } - void _showSavePresetDialog(BuildContext context, MainViewModel viewModel) { final nameController = TextEditingController(); final descriptionController = TextEditingController(); diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index cc050c8..b586bcc 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -4,7 +4,9 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../about_dialog.dart' as about; import '../../models/encoding_settings.dart'; import '../../models/video_job.dart'; import '../../services/dependency_manager.dart'; @@ -77,9 +79,9 @@ class _SettingsDialogState extends State TabBar( controller: _tabController, tabs: const [ - Tab(text: 'Input'), - Tab(text: 'Output'), Tab(text: 'General'), + Tab(text: 'Output'), + Tab(text: 'Input'), ], ), @@ -88,9 +90,9 @@ class _SettingsDialogState extends State child: TabBarView( controller: _tabController, children: const [ - _InputSettingsTab(), - _OutputSettingsTab(), _GeneralSettingsTab(), + _OutputSettingsTab(), + _InputSettingsTab(), ], ), ), @@ -1245,10 +1247,69 @@ class _GeneralSettingsTabState extends State<_GeneralSettingsTab> { onChanged: _setCheckForUpdates, ), ), + + const SizedBox(height: 24), + + _buildSection( + context, + title: 'About & Feedback', + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.bug_report_outlined), + title: const Text('Report a bug or give feedback'), + subtitle: const Text('Opens the issue tracker on GitHub'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => _openIssues(context), + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About VapourBox'), + subtitle: const Text('Version, licenses and credits'), + onTap: () => showDialog( + context: context, + builder: (context) => const about.AboutDialog(), + ), + ), + ], + ), + ), ], ); } + /// Opens the project's GitHub issues page in the browser, for bug reports and + /// feedback. The repo comes from `deps-version.json` (the same source the + /// About dialog uses) so a fork points at its own tracker. + Future _openIssues(BuildContext context) async { + var repo = 'StuartCameronCode/VapourBox'; + try { + final depsInfo = await DependencyManager.instance.getExpectedVersion(); + if (depsInfo.githubRepo.isNotEmpty) repo = depsInfo.githubRepo; + } catch (_) { + // Fall back to the default repo — a missing/unreadable pointer file + // shouldn't stop someone filing a bug. + } + + final url = 'https://github.com/$repo/issues'; + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else if (context.mounted) { + // No handler for https (rare on desktop) — show the URL so it can be + // copied rather than failing silently. + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open a browser. Visit $url'), + action: SnackBarAction( + label: 'Copy', + onPressed: () => Clipboard.setData(ClipboardData(text: url)), + ), + ), + ); + } + } + Widget _buildSection( BuildContext context, { required String title, From 449fc3f549594051454df82061c8f07b0b62fe67 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 2 Aug 2026 01:06:02 +1000 Subject: [PATCH 4/4] feat(settings): let the temp directory be overridden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scratch files — generated .vpy scripts, job config, preview frames, progress files and extracted DVD titles — always went to the system temp directory. A DVD rip alone can need several GB, which is a problem when system temp sits on a small or slow volume. Settings → General → Temporary Files now points them anywhere; the ✕ resets to the system default. The choice persists in shared_preferences. Two mechanisms cover the two processes. Dart call sites go through the new TempDirectoryService (worker job config, preview frames, DVD extraction, deps and whisper downloads). The worker needs no per-path plumbing: ToolLocator.workerEnvironment now sets TMPDIR/TMP/TEMP from the effective path, so every env::temp_dir() in the Rust side follows it — scripts, progress files, preview raw frames, GPU probes, the macOS vspipe conf — as do the ffmpeg and vspipe children. Those vars are applied per call rather than baked into the cached env map, so a change takes effect without a restart. Choosing a directory writes a probe file first, so a read-only volume is rejected at selection time rather than failing mid-job. If the directory later disappears, resolve() recreates it, falling back to system temp if it can't — a job in the wrong temp directory beats a job that can't run. Covered by app/test/temp_directory_service_test.dart; the worker half was verified by generating a script with TMPDIR pointed elsewhere. --- CLAUDE.md | 31 +++++ README.md | 12 ++ app/lib/main.dart | 5 + app/lib/services/dependency_manager.dart | 5 +- app/lib/services/preview_generator.dart | 6 +- app/lib/services/temp_directory_service.dart | 122 ++++++++++++++++++ app/lib/services/tool_locator.dart | 18 ++- app/lib/services/whisper_addon_manager.dart | 4 +- app/lib/services/worker_manager.dart | 6 +- app/lib/viewmodels/main_viewmodel.dart | 6 +- app/lib/views/settings/settings_dialog.dart | 97 +++++++++++++++ app/test/temp_directory_service_test.dart | 124 +++++++++++++++++++ 12 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 app/lib/services/temp_directory_service.dart create mode 100644 app/test/temp_directory_service_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index e174ff7..8b69393 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,7 @@ VapourBox/ | `app/lib/services/worker_manager.dart` | Process spawning, IPC | | `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/assets/filters/core/*.json` | Built-in filter schema definitions | ## Build Commands @@ -429,6 +430,36 @@ Filters are defined as JSON schemas in `app/assets/filters/core/` (built-in) or **`optional: true`**: Shows enable checkbox; when disabled, parameter is omitted (uses VS default). **`visibleWhen`**: Conditional visibility, e.g. `{ "method": ["method_a"] }`. +### Temporary Files Directory + +Scratch files default to the system temp directory, and the user can redirect +them in **Settings → General → Temporary Files** (✕ resets to the default). The +choice is persisted in shared_preferences under `tempDirectoryOverride` and +loaded by `TempDirectoryService.initialize()` in `main()`, before the first-run +dependency download — the earliest thing that writes a temp file. + +Two mechanisms cover the two processes, and both matter: + +- **Dart** call sites use `TempDirectoryService.instance` (`resolve()`, + `filePath()`, `createTemp()`). **Don't add new `Directory.systemTemp` uses** — + they would ignore the setting. Current users: worker job config + (`worker_manager`), preview frames/config (`preview_generator`), DVD + extraction (`main_viewmodel`), deps and whisper downloads. +- **Rust** needs no per-path plumbing: `ToolLocator.workerEnvironment` sets + `TMPDIR` (Unix) and `TMP`/`TEMP` (Windows) from the effective path, so every + `env::temp_dir()` in the worker — generated `.vpy` scripts, progress files, + preview raw frames, OpenCL/KNLM probes, the macOS vspipe conf — follows it, + as do the ffmpeg and vspipe children. Those vars are applied per call rather + than baked into the cached env map, so a change takes effect immediately. + **Any new worker/tool spawn must pass `workerEnvironment`** or it silently + reverts to the system temp directory. + +`resolve()` recreates the directory if missing and falls back to system temp if +it can't (external drive unplugged) — a job in the wrong temp directory beats a +job that can't run. `setOverride` verifies writability by writing a probe file, +so a read-only volume is rejected at selection time rather than at job time. +Covered by `app/test/temp_directory_service_test.dart`. + ### Preset System Presets save complete filter pipeline + encoding settings. Built-in presets (Fast, Balanced, High Quality, VHS Cleanup) are in `PresetService._createBuiltInPresets()`. User presets save to `~/.vapourbox/presets/*.json`. diff --git a/README.md b/README.md index 17dc420..2e7a57c 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,18 @@ Unencrypted DVDs (home recordings, some independent releases) work without libdv - **Built-in**: Fast, Balanced, High Quality, VHS Cleanup - **Save** your current settings for reuse across sessions +### Temporary Files + +VapourBox writes its scratch files — generated VapourSynth scripts, preview +frames, job files and extracted DVD titles — to the system temp directory. +DVD extraction in particular can need several GB, so if your system temp lives +on a small or slow volume you can redirect it: + +- **Settings → General → Temporary Files** → choose a directory +- Click the **✕** next to the path to reset it back to the system default + +The setting is remembered between sessions and applies to the next job. + ## Building from Source See [docs/BUILDING.md](docs/BUILDING.md) for build instructions, project structure, and development workflow. diff --git a/app/lib/main.dart b/app/lib/main.dart index 997d409..d353cdb 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -7,6 +7,7 @@ import 'models/filter_registry.dart'; import 'services/dependency_manager.dart'; import 'services/hardware_encoder_detector.dart'; import 'services/preset_service.dart'; +import 'services/temp_directory_service.dart'; import 'services/tool_locator.dart'; import 'services/update_checker.dart'; import 'viewmodels/main_viewmodel.dart'; @@ -20,6 +21,10 @@ void main() async { // Initialize rhttp (required for Rust FFI on Windows) await Rhttp.init(); + // Load the temp directory override before anything writes a scratch file + // (the dependency download on first run is the earliest of them). + await TempDirectoryService.instance.initialize(); + // Initialize window manager for desktop await windowManager.ensureInitialized(); diff --git a/app/lib/services/dependency_manager.dart b/app/lib/services/dependency_manager.dart index aa96ab3..d9e0753 100644 --- a/app/lib/services/dependency_manager.dart +++ b/app/lib/services/dependency_manager.dart @@ -9,6 +9,8 @@ import 'package:flutter/services.dart'; import 'package:rhttp/rhttp.dart'; import 'package:path/path.dart' as path; +import 'temp_directory_service.dart'; + /// Status of the dependency installation. enum DependencyStatus { /// Dependencies are installed and up-to-date @@ -418,7 +420,8 @@ class DependencyManager { await _fetchExpectedSha256(expected.getManifestUrl(platformId)); // Create temp file for download - final tempDir = await Directory.systemTemp.createTemp('vapourbox_deps_'); + final tempDir = + await TempDirectoryService.instance.createTemp('vapourbox_deps_'); final tempFile = File(path.join(tempDir.path, filename)); try { diff --git a/app/lib/services/preview_generator.dart b/app/lib/services/preview_generator.dart index a260ae9..5936286 100644 --- a/app/lib/services/preview_generator.dart +++ b/app/lib/services/preview_generator.dart @@ -10,6 +10,7 @@ import '../models/encoding_settings.dart'; import '../models/processing_pipeline.dart'; import '../models/video_job.dart'; import 'field_order_detector.dart'; +import 'temp_directory_service.dart'; import 'tool_locator.dart'; /// Service for generating video thumbnails and processed previews. @@ -71,8 +72,9 @@ class PreviewGenerator { _workerPath = toolLocator.workerPath; // Create temp directory for thumbnails and previews - final systemTemp = Directory.systemTemp; - _tempDir = '${systemTemp.path}/vapourbox_preview_${DateTime.now().millisecondsSinceEpoch}'; + final tempRoot = await TempDirectoryService.instance.resolve(); + _tempDir = + '${tempRoot.path}/vapourbox_preview_${DateTime.now().millisecondsSinceEpoch}'; await Directory(_tempDir!).create(recursive: true); } diff --git a/app/lib/services/temp_directory_service.dart b/app/lib/services/temp_directory_service.dart new file mode 100644 index 0000000..f43e045 --- /dev/null +++ b/app/lib/services/temp_directory_service.dart @@ -0,0 +1,122 @@ +import 'dart:io'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Where VapourBox puts its scratch files — generated `.vpy` scripts, job +/// config JSON, preview frames, progress files and extracted DVD titles. +/// +/// The system temp directory is the default. A user whose system temp lives on +/// a small or slow volume (or a RAM disk that can't hold a DVD rip) can point +/// this elsewhere; [setOverride] with null goes back to the system default. +/// +/// The worker and the tools it spawns pick this up through `TMPDIR`/`TMP`/ +/// `TEMP`, which [ToolLocator.workerEnvironment] sets from [effectivePath] — +/// that's what redirects the Rust side's `env::temp_dir()` calls, so there is +/// no separate path to keep in sync. +class TempDirectoryService { + static final TempDirectoryService instance = TempDirectoryService._(); + TempDirectoryService._(); + + static const String _prefsKey = 'tempDirectoryOverride'; + + String? _override; + bool _loaded = false; + + /// The user's chosen directory, or null when using the system default. + String? get override => _override; + + /// The system temp directory, shown in the UI as the default. + String get systemDefault => Directory.systemTemp.path; + + /// Directory scratch files should go in: the override when set, otherwise + /// the system temp directory. + String get effectivePath => _override ?? systemDefault; + + /// Load the saved override. Call once at startup, before anything writes a + /// temp file. Safe to call again; only the first call reads storage. + Future initialize() async { + if (_loaded) return; + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_prefsKey); + _override = (stored != null && stored.isNotEmpty) ? stored : null; + } catch (_) { + // Unreadable preferences shouldn't stop the app starting — the system + // default is a working fallback. + _override = null; + } + _loaded = true; + } + + /// Set the override directory, or pass null to go back to the system + /// default. Throws if the directory can't be created or written to, so the + /// caller can report it rather than storing a path that fails at job time. + Future setOverride(String? directory) async { + final trimmed = directory?.trim(); + final value = (trimmed == null || trimmed.isEmpty) ? null : trimmed; + + if (value != null) { + await _verifyWritable(Directory(value)); + } + + _override = value; + _loaded = true; + + try { + final prefs = await SharedPreferences.getInstance(); + if (value == null) { + await prefs.remove(_prefsKey); + } else { + await prefs.setString(_prefsKey, value); + } + } catch (_) { + // Keep the in-memory choice for this session even if it can't be saved. + } + } + + /// The directory to write into, created if missing. + /// + /// Falls back to the system temp directory if the override has become + /// unusable — an external drive unplugged since it was chosen, say. A job + /// running in the wrong temp directory beats a job that can't run at all. + Future resolve() async { + final chosen = Directory(effectivePath); + try { + if (!await chosen.exists()) { + await chosen.create(recursive: true); + } + return chosen; + } catch (_) { + return Directory.systemTemp; + } + } + + /// Creates a uniquely-named directory under [resolve], like + /// `Directory.systemTemp.createTemp`. + Future createTemp(String prefix) async { + final parent = await resolve(); + return parent.createTemp(prefix); + } + + /// Builds a path for a scratch file inside the temp directory, creating the + /// directory if needed. + Future filePath(String filename) async { + final dir = await resolve(); + return '${dir.path}${Platform.pathSeparator}$filename'; + } + + /// Throws if [dir] can't be created or written to. + Future _verifyWritable(Directory dir) async { + if (!await dir.exists()) { + await dir.create(recursive: true); + } + // Existence isn't enough — a read-only volume passes that and then fails + // at job time, which is far harder to diagnose. + final probe = File( + '${dir.path}${Platform.pathSeparator}.vapourbox_write_test_' + '${DateTime.now().microsecondsSinceEpoch}', + ); + await probe.writeAsString(''); + await probe.delete(); + } +} diff --git a/app/lib/services/tool_locator.dart b/app/lib/services/tool_locator.dart index f2a8080..220626b 100644 --- a/app/lib/services/tool_locator.dart +++ b/app/lib/services/tool_locator.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as path; import 'dependency_manager.dart'; +import 'temp_directory_service.dart'; /// Centralized service for locating bundled external tools (ffmpeg, ffprobe, /// vspipe, vapourbox-worker) and the platform deps directory. @@ -42,8 +43,21 @@ class ToolLocator { /// Environment variables for spawning worker/tool processes. /// Includes PYTHONHOME, PYTHONPATH, VAPOURSYNTH_PLUGIN_PATH, PATH, etc. - Map get workerEnvironment => - _workerEnvironment ?? Map.from(Platform.environment); + /// + /// The temp variables are applied per call rather than baked into the cached + /// map, so changing the temp directory in Settings takes effect immediately. + /// They point the worker's `env::temp_dir()` — and ffmpeg's and vspipe's — at + /// the configured directory: TMPDIR for Unix, TMP/TEMP for Windows. + Map get workerEnvironment { + final env = Map.from( + _workerEnvironment ?? Platform.environment, + ); + final tempPath = TempDirectoryService.instance.effectivePath; + env['TMPDIR'] = tempPath; + env['TMP'] = tempPath; + env['TEMP'] = tempPath; + return env; + } /// The current platform identifier (e.g. `windows-x64`, `macos-arm64`). String get platformId => DependencyManager.instance.platformId; diff --git a/app/lib/services/whisper_addon_manager.dart b/app/lib/services/whisper_addon_manager.dart index c5dbfc4..e9e15aa 100644 --- a/app/lib/services/whisper_addon_manager.dart +++ b/app/lib/services/whisper_addon_manager.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:path/path.dart' as path; import 'addon_manager.dart'; +import 'temp_directory_service.dart'; /// Manages Whisper add-on binary and model downloads. class WhisperAddonManager { @@ -162,7 +163,8 @@ class WhisperAddonManager { final whisperDir = await getWhisperDir(); await whisperDir.create(recursive: true); - final tempDir = await Directory.systemTemp.createTemp('vapourbox_whisper_'); + final tempDir = + await TempDirectoryService.instance.createTemp('vapourbox_whisper_'); final tempFile = File(path.join(tempDir.path, (format == 'homebrew-bottle' || format == 'tar') ? 'whisper.tar.gz' diff --git a/app/lib/services/worker_manager.dart b/app/lib/services/worker_manager.dart index d9217f5..5c30982 100644 --- a/app/lib/services/worker_manager.dart +++ b/app/lib/services/worker_manager.dart @@ -4,6 +4,7 @@ import 'dart:io'; import '../models/progress_info.dart'; import '../models/video_job.dart'; +import 'temp_directory_service.dart'; import 'tool_locator.dart'; /// Manages the worker process lifecycle and IPC. @@ -48,8 +49,9 @@ class WorkerManager { } // Write job config to temp file - final tempDir = Directory.systemTemp; - final configFile = File('${tempDir.path}/vapourbox_job_${job.id}.json'); + final configFile = File( + await TempDirectoryService.instance.filePath('vapourbox_job_${job.id}.json'), + ); await configFile.writeAsString(jsonEncode(job.toJson())); try { diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index 6169c82..08d610a 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -20,6 +20,7 @@ import '../services/field_order_detector.dart'; import '../services/frame_math.dart'; import '../services/preset_service.dart'; import '../services/preview_generator.dart'; +import '../services/temp_directory_service.dart'; import '../services/worker_manager.dart'; /// Main view model managing application state. @@ -1358,9 +1359,10 @@ class MainViewModel extends ChangeNotifier { // Create temp file for the extraction. // Sanitize volume label to remove characters illegal in filenames (e.g., ":" from "D:\"). final safeLabel = dvdInfo.volumeLabel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); - final tempDir = Directory.systemTemp; final tempFile = File( - '${tempDir.path}/vapourbox_dvd_${safeLabel}_t${titleIndex}_${DateTime.now().millisecondsSinceEpoch}.mpg', + await TempDirectoryService.instance.filePath( + 'vapourbox_dvd_${safeLabel}_t${titleIndex}_${DateTime.now().millisecondsSinceEpoch}.mpg', + ), ); final dvdSourceInfo = DvdSourceInfo( diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index b586bcc..8581809 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -11,6 +11,7 @@ import '../../models/encoding_settings.dart'; import '../../models/video_job.dart'; import '../../services/dependency_manager.dart'; import '../../services/hardware_encoder_detector.dart'; +import '../../services/temp_directory_service.dart'; import '../../services/update_checker.dart'; import '../../utils/pixel_format.dart'; import '../../viewmodels/main_viewmodel.dart'; @@ -1205,12 +1206,49 @@ class _GeneralSettingsTabState extends State<_GeneralSettingsTab> { bool _checkForUpdates = true; bool _isLoading = true; + /// Temp directory override, or null when using the system default. Mirrors + /// [TempDirectoryService] so the row repaints as soon as it's changed. + String? _tempOverride; + @override void initState() { super.initState(); + _tempOverride = TempDirectoryService.instance.override; _loadSettings(); } + /// Pick a directory for scratch files. + Future _selectTempDirectory() async { + final result = await FilePicker.platform.getDirectoryPath( + dialogTitle: 'Select Temporary Files Directory', + initialDirectory: _tempOverride, + ); + if (result != null) { + await _setTempDirectory(result); + } + } + + /// Apply a temp directory choice; null resets to the system default. The + /// service verifies the directory is writable, so report a failure rather + /// than storing a path that would break the next job. + Future _setTempDirectory(String? directory) async { + try { + await TempDirectoryService.instance.setOverride(directory); + if (mounted) { + setState(() => _tempOverride = TempDirectoryService.instance.override); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Cannot use that directory: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + Future _loadSettings() async { final enabled = await UpdateChecker.instance.isEnabled(); if (mounted) { @@ -1250,6 +1288,65 @@ class _GeneralSettingsTabState extends State<_GeneralSettingsTab> { const SizedBox(height: 24), + _buildSection( + context, + title: 'Temporary Files', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + _tempOverride ?? + 'System default (${TempDirectoryService.instance.systemDefault})', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontStyle: _tempOverride == null + ? FontStyle.italic + : FontStyle.normal, + ), + ), + ), + if (_tempOverride != null) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () => _setTempDirectory(null), + tooltip: 'Reset to system default', + ), + IconButton( + icon: const Icon(Icons.folder_open), + onPressed: _selectTempDirectory, + tooltip: 'Choose directory', + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + 'Generated VapourSynth scripts, preview frames, job files and ' + 'extracted DVD titles are written here. Point it at a fast ' + 'drive with room to spare — DVD extraction alone can need ' + 'several GB. Takes effect for the next job.', + 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', diff --git a/app/test/temp_directory_service_test.dart b/app/test/temp_directory_service_test.dart new file mode 100644 index 0000000..2d98448 --- /dev/null +++ b/app/test/temp_directory_service_test.dart @@ -0,0 +1,124 @@ +// Tests for the configurable scratch-file directory: the system default, a +// user override, resetting back, and the fallbacks that keep a job runnable +// when the chosen directory has gone away. +// +// Run with: flutter test test/temp_directory_service_test.dart + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vapourbox/services/temp_directory_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final service = TempDirectoryService.instance; + late Directory sandbox; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + sandbox = await Directory.systemTemp.createTemp('vb_temp_service_test_'); + // The singleton carries state between tests; start each one at the default. + await service.setOverride(null); + }); + + tearDown(() async { + await service.setOverride(null); + if (await sandbox.exists()) { + await sandbox.delete(recursive: true); + } + }); + + test('defaults to the system temp directory', () async { + expect(service.override, isNull); + expect(service.effectivePath, Directory.systemTemp.path); + expect((await service.resolve()).path, Directory.systemTemp.path); + }); + + test('an override redirects resolve() and file paths', () async { + await service.setOverride(sandbox.path); + + expect(service.override, sandbox.path); + expect(service.effectivePath, sandbox.path); + expect((await service.resolve()).path, sandbox.path); + + final filePath = await service.filePath('job.json'); + expect(filePath, startsWith(sandbox.path)); + expect(filePath, endsWith('job.json')); + + final scratch = await service.createTemp('unit_'); + expect(scratch.parent.path, sandbox.path); + await scratch.delete(recursive: true); + }); + + test('setOverride(null) resets to the system default', () async { + await service.setOverride(sandbox.path); + await service.setOverride(null); + + expect(service.override, isNull); + expect(service.effectivePath, Directory.systemTemp.path); + }); + + test('an empty or whitespace path is treated as a reset', () async { + await service.setOverride(sandbox.path); + await service.setOverride(' '); + + expect(service.override, isNull); + }); + + test('a missing override directory is created', () async { + final nested = '${sandbox.path}${Platform.pathSeparator}made/by/the/service'; + await service.setOverride(nested); + + expect(await Directory(nested).exists(), isTrue); + // The writability probe must not leave anything behind. + expect(await Directory(nested).list().isEmpty, isTrue); + }); + + test('an unusable directory is rejected and the old value kept', () async { + await service.setOverride(sandbox.path); + + // A path whose parent is a file can't be created. + final blocker = File('${sandbox.path}${Platform.pathSeparator}not_a_dir'); + await blocker.writeAsString('x'); + + await expectLater( + service.setOverride('${blocker.path}${Platform.pathSeparator}child'), + throwsA(isA()), + ); + expect(service.override, sandbox.path); + }); + + test('resolve() falls back to system temp if the override disappears', + () async { + final removable = + await Directory.systemTemp.createTemp('vb_temp_service_gone_'); + await service.setOverride(removable.path); + await removable.delete(recursive: true); + + // It is recreated when it can be... + expect((await service.resolve()).path, removable.path); + expect(await removable.exists(), isTrue); + await removable.delete(recursive: true); + + // ...and when it can't (parent replaced by a file), the job still gets a + // working directory rather than an exception. + final parentAsFile = File(removable.path); + await parentAsFile.writeAsString('x'); + expect((await service.resolve()).path, Directory.systemTemp.path); + await parentAsFile.delete(); + }); + + test('the override survives a reload from preferences', () async { + await service.setOverride(sandbox.path); + + // initialize() is a no-op once loaded; prove the value was persisted by + // reading the same key back out of preferences. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('tempDirectoryOverride'), sandbox.path); + + await service.setOverride(null); + expect(prefs.getString('tempDirectoryOverride'), isNull); + }); +}