Skip to content

Commit af8ba75

Browse files
shioneko2026claude
andcommitted
Add UI improvements: scan page buttons, settings per-section controls, duration selection, and beep fix
Scan page: rename Save to Save Paths, add Clear Paths and Settings buttons, replace scan-complete link with prominent banner and large results button. Settings page: add Save Settings and Default Values buttons to Similarity, Scanning, and FFmpeg sections. Remove misleading auto-redirect claim from beep checkbox label. Results page: add shortest/longest duration auto-select, improve no-audio selection to skip all-silent groups, add per-group duration/no-audio buttons. Fix beep reliability by reusing a persistent AudioContext unlocked on first user interaction instead of creating a new context each time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 930b918 commit af8ba75

5 files changed

Lines changed: 231 additions & 52 deletions

File tree

VDF.Web/Components/Layout/MainLayout.razor

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
@inherits LayoutComponentBase
22

33
<script>
4-
window.vdfBeep = function() {
5-
try {
6-
var ctx = new (window.AudioContext || window.webkitAudioContext)();
7-
var osc = ctx.createOscillator();
8-
var gain = ctx.createGain();
9-
osc.connect(gain);
10-
gain.connect(ctx.destination);
11-
osc.type = 'sine';
12-
osc.frequency.value = 880;
13-
gain.gain.setValueAtTime(0.3, ctx.currentTime);
14-
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
15-
osc.start(ctx.currentTime);
16-
osc.stop(ctx.currentTime + 0.4);
17-
} catch(e) {}
18-
};
4+
(function() {
5+
var _ctx = null;
6+
function getCtx() {
7+
if (!_ctx) _ctx = new (window.AudioContext || window.webkitAudioContext)();
8+
if (_ctx.state === 'suspended') _ctx.resume();
9+
return _ctx;
10+
}
11+
// Unlock audio on first user interaction
12+
['click','keydown','touchstart'].forEach(function(evt) {
13+
document.addEventListener(evt, function unlock() {
14+
getCtx();
15+
document.removeEventListener(evt, unlock);
16+
}, { once: true });
17+
});
18+
window.vdfBeep = function() {
19+
try {
20+
var ctx = getCtx();
21+
var osc = ctx.createOscillator();
22+
var gain = ctx.createGain();
23+
osc.connect(gain);
24+
gain.connect(ctx.destination);
25+
osc.type = 'sine';
26+
osc.frequency.value = 880;
27+
gain.gain.setValueAtTime(0.3, ctx.currentTime);
28+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
29+
osc.start(ctx.currentTime);
30+
osc.stop(ctx.currentTime + 0.4);
31+
} catch(e) { console.warn('vdfBeep failed:', e); }
32+
};
33+
})();
1934
</script>
2035

2136
<div class="app-shell">

VDF.Web/Components/Pages/Index.razor

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ else {
141141
@if (Scan.State == ScanState.Done || Scan.State == ScanState.Aborted || Scan.State == ScanState.Error) {
142142
<button @onclick="Scan.Reset">Reset</button>
143143
}
144-
<button @onclick="SaveSettings" title="Save current settings to disk">Save settings</button>
144+
<button @onclick="SavePaths" title="Save current paths to disk">Save paths</button>
145+
<button @onclick="ClearPaths" title="Clear all include and exclude paths">Clear paths</button>
146+
<button @onclick='() => Nav.NavigateTo("/settings")' title="Open settings">Settings</button>
145147
<button class="primary" @onclick="Scan.StartScanAndCompare"
146148
disabled="@(Scan.OrderedIncludeList.Count == 0 || !FfmpegSetup.IsReady)"
147149
title="@(!FfmpegSetup.IsReady ? "Waiting for FFmpeg setup to complete..." : null)">
@@ -152,14 +154,22 @@ else {
152154
@if (_saveMsg != null) {
153155
<p class="@(_saveFailed ? "aborted-msg" : "done-msg")">@_saveMsg</p>
154156
}
155-
@if (Scan.State == ScanState.Done) {
156-
<p class="done-msg">Scan complete<a href="/results">view @Scan.Duplicates.Count result(s)</a></p>
157-
}
158157
@if (Scan.State == ScanState.Aborted) {
159158
<p class="aborted-msg">Scan was stopped.</p>
160159
}
161160

162161
</section>
162+
163+
@if (Scan.State == ScanState.Done) {
164+
<div style="margin-top:24px; padding:40px 32px; text-align:center; background:#f0fdf4; border:3px solid #86efac; border-radius:14px;">
165+
<div style="font-size:36px; font-weight:800; color:#16a34a; margin-bottom:8px;">Scan Complete!</div>
166+
<div style="font-size:20px; color:#374151; margin-bottom:28px;">@Scan.Duplicates.Count duplicate group(s) found</div>
167+
<a href="/results"
168+
style="display:inline-block; padding:20px 56px; background:#16a34a; color:#fff; font-size:22px; font-weight:700; border-radius:12px; text-decoration:none; cursor:pointer;">
169+
Take me to the Results
170+
</a>
171+
</div>
172+
}
163173
}
164174
</div>
165175

@@ -333,17 +343,25 @@ else {
333343
_browserOpen = false;
334344
}
335345

336-
// ── Settings
337-
void SaveSettings() {
346+
// ── Paths
347+
void SavePaths() {
338348
bool ok = Scan.SaveSettings();
339-
_saveMsg = ok ? "Settings saved." : "Failed to save settings.";
349+
_saveMsg = ok ? "Paths saved." : "Failed to save paths.";
340350
_saveFailed = !ok;
341351
_ = Task.Delay(3000).ContinueWith(_ => InvokeAsync(() => {
342352
_saveMsg = null;
343353
StateHasChanged();
344354
}));
345355
}
346356

357+
void ClearPaths() {
358+
Scan.OrderedIncludeList.Clear();
359+
Scan.Settings.IncludeList.Clear();
360+
Scan.Settings.BlackList.Clear();
361+
_newInclude = string.Empty;
362+
_newExclude = string.Empty;
363+
}
364+
347365
static string FormatEta(TimeSpan eta) =>
348366
eta == TimeSpan.Zero ? "..." : eta.ToString(@"m\mss\s");
349367

VDF.Web/Components/Pages/Results.razor

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@
3434
<button @onclick="() => AutoSelect(AutoSelectMode.Oldest)">Oldest (keep newest)</button>
3535
<button @onclick="() => AutoSelect(AutoSelectMode.Newest)">Newest (keep oldest)</button>
3636
<button @onclick="() => AutoSelect(AutoSelectMode.HundredPercentEqual)">= 100% equal groups</button>
37+
<button @onclick="SelectShortest"
38+
title="Flags the shortest file in each group — skips groups where all files have the same duration.">Shortest duration</button>
39+
<button @onclick="SelectLongest"
40+
title="Flags the longest file in each group — skips groups where all files have the same duration.">Longest duration</button>
3741
<hr />
3842
<div class="dropdown-section">By Property</div>
39-
<button @onclick="SelectNoAudio">🔇 No audio track</button>
43+
<button @onclick="SelectNoAudio"
44+
title="Selects files with no audio track, group by group. Groups where ALL files have no audio are skipped entirely — if no file in the group has audio, there is no good copy to compare against.">🔇 No audio track</button>
4045
<hr />
4146
<button @onclick="InvertSelection">Invert selection</button>
4247
<button @onclick="ClearSelection">Deselect all</button>
@@ -158,8 +163,14 @@
158163
<button class="btn-sm" @onclick="() => ClearGroupSelection(items)"
159164
disabled="@(!items.Any(x => _flagged.Contains(x.Path)))">Unselect</button>
160165
<button class="btn-sm" @onclick="() => { FlagNoAudio(items); _openGroupDropdown = Guid.Empty; }"
161-
disabled="@(!items.Any(x => !x.IsImage && string.IsNullOrEmpty(x.AudioFormat) && !_deleted.Contains(x.Path)))"
162-
title="Flag files in this group that have no audio track">🔇 Select No Audio Track</button>
166+
disabled="@(!(items.Any(x => !x.IsImage && string.IsNullOrEmpty(x.AudioFormat) && !_deleted.Contains(x.Path)) && items.Any(x => !x.IsImage && !string.IsNullOrEmpty(x.AudioFormat) && !_deleted.Contains(x.Path))))"
167+
title="Selects files with no audio track — only works when at least one file in this group has audio. If every file here has no audio, nothing is selected (can't tell which is the degraded copy).">🔇 Select No Audio Track</button>
168+
<button class="btn-sm" @onclick="() => FlagShortestDuration(items)"
169+
disabled="@(items.Where(x => !_deleted.Contains(x.Path)).Select(x => x.Duration).Distinct().Count() <= 1)"
170+
title="Flags the shortest file in this group. Does nothing if all files have the same duration.">Shortest</button>
171+
<button class="btn-sm" @onclick="() => FlagLongestDuration(items)"
172+
disabled="@(items.Where(x => !_deleted.Contains(x.Path)).Select(x => x.Duration).Distinct().Count() <= 1)"
173+
title="Flags the longest file in this group. Does nothing if all files have the same duration.">Longest</button>
163174

164175
<div class="toolbar-sep"></div>
165176

@@ -434,8 +445,30 @@
434445
_flagged.Add(i.Path);
435446
}
436447
void FlagNoAudio(List<DuplicateItem> items) {
448+
var eligible = items.Where(x => !x.IsImage && !_deleted.Contains(x.Path)).ToList();
449+
if (!eligible.Any(x => !string.IsNullOrEmpty(x.AudioFormat))) return; // all silent — do nothing
437450
ClearGroupSelection(items);
438-
foreach (var i in items.Where(x => !x.IsImage && string.IsNullOrEmpty(x.AudioFormat) && !_deleted.Contains(x.Path)))
451+
foreach (var i in eligible.Where(x => string.IsNullOrEmpty(x.AudioFormat)))
452+
_flagged.Add(i.Path);
453+
}
454+
455+
void FlagShortestDuration(List<DuplicateItem> items) {
456+
var eligible = items.Where(x => !x.IsImage && !_deleted.Contains(x.Path)).ToList();
457+
if (eligible.Count < 2) return;
458+
var min = eligible.Min(x => x.Duration);
459+
if (eligible.Max(x => x.Duration) == min) return; // all same — do nothing
460+
ClearGroupSelection(items);
461+
foreach (var i in eligible.Where(x => x.Duration == min))
462+
_flagged.Add(i.Path);
463+
}
464+
465+
void FlagLongestDuration(List<DuplicateItem> items) {
466+
var eligible = items.Where(x => !x.IsImage && !_deleted.Contains(x.Path)).ToList();
467+
if (eligible.Count < 2) return;
468+
var max = eligible.Max(x => x.Duration);
469+
if (eligible.Min(x => x.Duration) == max) return; // all same — do nothing
470+
ClearGroupSelection(items);
471+
foreach (var i in eligible.Where(x => x.Duration == max))
439472
_flagged.Add(i.Path);
440473
}
441474

@@ -455,8 +488,38 @@
455488
void SelectNoAudio() {
456489
_autoSelectOpen = false;
457490
_flagged.Clear();
458-
foreach (var item in Scan.Duplicates.Where(d => !d.IsImage && string.IsNullOrEmpty(d.AudioFormat) && !_deleted.Contains(d.Path)))
459-
_flagged.Add(item.Path);
491+
foreach (var group in Scan.Duplicates.GroupBy(d => d.GroupId)) {
492+
var items = group.Where(d => !d.IsImage && !_deleted.Contains(d.Path)).ToList();
493+
if (!items.Any(d => !string.IsNullOrEmpty(d.AudioFormat))) continue; // all silent — skip group
494+
foreach (var d in items.Where(d => string.IsNullOrEmpty(d.AudioFormat)))
495+
_flagged.Add(d.Path);
496+
}
497+
}
498+
499+
void SelectShortest() {
500+
_autoSelectOpen = false;
501+
_flagged.Clear();
502+
foreach (var group in Scan.Duplicates.GroupBy(d => d.GroupId)) {
503+
var items = group.Where(d => !d.IsImage && !_deleted.Contains(d.Path)).ToList();
504+
if (items.Count < 2) continue;
505+
var min = items.Min(d => d.Duration);
506+
if (items.Max(d => d.Duration) == min) continue; // all same — skip
507+
foreach (var d in items.Where(d => d.Duration == min))
508+
_flagged.Add(d.Path);
509+
}
510+
}
511+
512+
void SelectLongest() {
513+
_autoSelectOpen = false;
514+
_flagged.Clear();
515+
foreach (var group in Scan.Duplicates.GroupBy(d => d.GroupId)) {
516+
var items = group.Where(d => !d.IsImage && !_deleted.Contains(d.Path)).ToList();
517+
if (items.Count < 2) continue;
518+
var max = items.Max(d => d.Duration);
519+
if (items.Min(d => d.Duration) == max) continue; // all same — skip
520+
foreach (var d in items.Where(d => d.Duration == max))
521+
_flagged.Add(d.Path);
522+
}
460523
}
461524
void InvertSelection() {
462525
_autoSelectOpen = false;

0 commit comments

Comments
 (0)