GUI improvements

This commit is contained in:
whimsical-c4lic0 2026-02-18 04:29:51 -06:00
parent a74ebc810a
commit 36dbb3de5d
6 changed files with 184 additions and 30 deletions

View File

@ -1,6 +1,4 @@
using System.Collections;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Documents; using Avalonia.Controls.Documents;
@ -14,7 +12,8 @@ namespace OF_DL.Gui.Controls;
public class FileNameFormatOverlayTextBlock : TextBlock public class FileNameFormatOverlayTextBlock : TextBlock
{ {
public static readonly StyledProperty<IEnumerable<FileNameFormatSegmentViewModel>?> SegmentsProperty = public static readonly StyledProperty<IEnumerable<FileNameFormatSegmentViewModel>?> SegmentsProperty =
AvaloniaProperty.Register<FileNameFormatOverlayTextBlock, IEnumerable<FileNameFormatSegmentViewModel>?>(nameof(Segments)); AvaloniaProperty.Register<FileNameFormatOverlayTextBlock, IEnumerable<FileNameFormatSegmentViewModel>?>(
nameof(Segments));
public static readonly StyledProperty<TextBox?> SourceTextBoxProperty = public static readonly StyledProperty<TextBox?> SourceTextBoxProperty =
AvaloniaProperty.Register<FileNameFormatOverlayTextBlock, TextBox?>(nameof(SourceTextBox)); AvaloniaProperty.Register<FileNameFormatOverlayTextBlock, TextBox?>(nameof(SourceTextBox));
@ -68,10 +67,8 @@ public class FileNameFormatOverlayTextBlock : TextBlock
private static void OnSourceTextBoxChanged( private static void OnSourceTextBoxChanged(
FileNameFormatOverlayTextBlock sender, FileNameFormatOverlayTextBlock sender,
AvaloniaPropertyChangedEventArgs e) AvaloniaPropertyChangedEventArgs e) =>
{
sender.AttachSourceTextBox(e.NewValue as TextBox); sender.AttachSourceTextBox(e.NewValue as TextBox);
}
private void AttachSegmentsCollection(IEnumerable<FileNameFormatSegmentViewModel>? segments) private void AttachSegmentsCollection(IEnumerable<FileNameFormatSegmentViewModel>? segments)
{ {
@ -93,10 +90,7 @@ public class FileNameFormatOverlayTextBlock : TextBlock
_segmentsCollection = null; _segmentsCollection = null;
} }
private void OnSegmentsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) private void OnSegmentsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildInlines();
{
RebuildInlines();
}
private void AttachSourceTextBox(TextBox? textBox) private void AttachSourceTextBox(TextBox? textBox)
{ {
@ -143,10 +137,7 @@ public class FileNameFormatOverlayTextBlock : TextBlock
UpdateOverlayOffset(); UpdateOverlayOffset();
} }
private void OnSourceScrollChanged(object? sender, ScrollChangedEventArgs e) private void OnSourceScrollChanged(object? sender, ScrollChangedEventArgs e) => UpdateOverlayOffset();
{
UpdateOverlayOffset();
}
private void AttachScrollViewer(ScrollViewer? scrollViewer) private void AttachScrollViewer(ScrollViewer? scrollViewer)
{ {
@ -203,11 +194,7 @@ public class FileNameFormatOverlayTextBlock : TextBlock
foreach (FileNameFormatSegmentViewModel segment in Segments) foreach (FileNameFormatSegmentViewModel segment in Segments)
{ {
Run run = new() Run run = new() { Text = segment.Text, Foreground = ParseForegroundBrush(segment.Foreground) };
{
Text = segment.Text ?? string.Empty,
Foreground = ParseForegroundBrush(segment.Foreground)
};
Inlines.Add(run); Inlines.Add(run);
} }
} }

View File

@ -13,6 +13,10 @@ internal sealed class AvaloniaDownloadEventHandler(
CancellationToken cancellationToken) : IDownloadEventHandler CancellationToken cancellationToken) : IDownloadEventHandler
{ {
private string _lastProgressDescription = string.Empty; private string _lastProgressDescription = string.Empty;
private string _activeUsername = string.Empty;
private DateTime _activeUserStartedAtUtc;
private readonly Dictionary<string, int> _contentObjectCounts = new(StringComparer.Ordinal);
private bool _hasPerUserCompletionLogged;
public CancellationToken CancellationToken { get; } = cancellationToken; public CancellationToken CancellationToken { get; } = cancellationToken;
@ -51,6 +55,7 @@ internal sealed class AvaloniaDownloadEventHandler(
public void OnContentFound(string contentType, int mediaCount, int objectCount) public void OnContentFound(string contentType, int mediaCount, int objectCount)
{ {
ThrowIfCancellationRequested(); ThrowIfCancellationRequested();
_contentObjectCounts[contentType] = objectCount;
progressStatusUpdate($"Found {mediaCount} media from {objectCount} {contentType}."); progressStatusUpdate($"Found {mediaCount} media from {objectCount} {contentType}.");
} }
@ -63,6 +68,19 @@ internal sealed class AvaloniaDownloadEventHandler(
public void OnDownloadComplete(string contentType, DownloadResult result) public void OnDownloadComplete(string contentType, DownloadResult result)
{ {
ThrowIfCancellationRequested(); ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(_activeUsername) && result.NewDownloads > 0)
{
if (_contentObjectCounts.TryGetValue(contentType, out int objectCount) && objectCount > 0)
{
activitySink(
$"Downloaded {result.NewDownloads} media from {objectCount} {contentType.ToLowerInvariant()}.");
}
else
{
activitySink($"Downloaded {result.NewDownloads} media from {contentType.ToLowerInvariant()}.");
}
}
progressStatusUpdate( progressStatusUpdate(
$"{contentType} complete. Existing: {result.ExistingDownloads}, New: {result.NewDownloads}, Total: {result.TotalCount}."); $"{contentType} complete. Existing: {result.ExistingDownloads}, New: {result.NewDownloads}, Total: {result.TotalCount}.");
} }
@ -70,6 +88,9 @@ internal sealed class AvaloniaDownloadEventHandler(
public void OnUserStarting(string username) public void OnUserStarting(string username)
{ {
ThrowIfCancellationRequested(); ThrowIfCancellationRequested();
_activeUsername = username;
_activeUserStartedAtUtc = DateTime.UtcNow;
_hasPerUserCompletionLogged = false;
activitySink($"Starting scrape for {username}."); activitySink($"Starting scrape for {username}.");
progressStatusUpdate($"Scraping data for {username}..."); progressStatusUpdate($"Scraping data for {username}...");
} }
@ -77,19 +98,31 @@ internal sealed class AvaloniaDownloadEventHandler(
public void OnUserComplete(string username, CreatorDownloadResult result) public void OnUserComplete(string username, CreatorDownloadResult result)
{ {
ThrowIfCancellationRequested(); ThrowIfCancellationRequested();
activitySink( TimeSpan elapsed = DateTime.UtcNow - _activeUserStartedAtUtc;
$"Completed {username}. PaidPosts={result.PaidPostCount}, Posts={result.PostCount}, Archived={result.ArchivedCount}, Streams={result.StreamsCount}, Stories={result.StoriesCount}, Highlights={result.HighlightsCount}, Messages={result.MessagesCount}, PaidMessages={result.PaidMessagesCount}."); activitySink($"Completed {username} in {elapsed.TotalMinutes:0.0} minutes.");
_activeUsername = string.Empty;
_hasPerUserCompletionLogged = true;
_contentObjectCounts.Clear();
} }
public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount) public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount)
{ {
ThrowIfCancellationRequested(); ThrowIfCancellationRequested();
activitySink($"Purchased tab complete for {username}. PaidPosts={paidPostCount}, PaidMessages={paidMessagesCount}."); TimeSpan elapsed = DateTime.UtcNow - _activeUserStartedAtUtc;
activitySink($"Completed {username} in {elapsed.TotalMinutes:0.0} minutes.");
_activeUsername = string.Empty;
_hasPerUserCompletionLogged = true;
_contentObjectCounts.Clear();
} }
public void OnScrapeComplete(TimeSpan elapsed) public void OnScrapeComplete(TimeSpan elapsed)
{ {
ThrowIfCancellationRequested(); ThrowIfCancellationRequested();
if (_hasPerUserCompletionLogged)
{
return;
}
string summary = BuildCompletionSummary(elapsed); string summary = BuildCompletionSummary(elapsed);
activitySink(summary); activitySink(summary);
} }

View File

@ -154,6 +154,9 @@ public partial class ConfigFieldViewModel : ViewModelBase
public bool IsTimeoutField => public bool IsTimeoutField =>
string.Equals(PropertyName, nameof(Config.Timeout), StringComparison.Ordinal); string.Equals(PropertyName, nameof(Config.Timeout), StringComparison.Ordinal);
public bool IsHideMissingCdmKeysWarningField =>
string.Equals(PropertyName, nameof(Config.HideMissingCdmKeysWarning), StringComparison.Ordinal);
public bool IsRegularTextInput => public bool IsRegularTextInput =>
IsTextInput && !IsIgnoredUsersListField && !IsCreatorConfigsField && !IsFileNameFormatField; IsTextInput && !IsIgnoredUsersListField && !IsCreatorConfigsField && !IsFileNameFormatField;

View File

@ -179,6 +179,8 @@ public partial class MainWindowViewModel(
["HelpBadgeBackgroundBrush"] = "#EAF0FB", ["HelpBadgeBackgroundBrush"] = "#EAF0FB",
["HelpBadgeBorderBrush"] = "#C5D4EC", ["HelpBadgeBorderBrush"] = "#C5D4EC",
["ErrorTextBrush"] = "#FF5A5A", ["ErrorTextBrush"] = "#FF5A5A",
["SuccessTextBrush"] = "#2C8A4B",
["WarningTextBrush"] = "#B16A00",
["PreviewBackgroundBrush"] = "#F5F8FE", ["PreviewBackgroundBrush"] = "#F5F8FE",
["PreviewBorderBrush"] = "#D8E3F4", ["PreviewBorderBrush"] = "#D8E3F4",
["DangerSoftBackgroundBrush"] = "#FFE8E8", ["DangerSoftBackgroundBrush"] = "#FFE8E8",
@ -207,6 +209,8 @@ public partial class MainWindowViewModel(
["HelpBadgeBackgroundBrush"] = "#233145", ["HelpBadgeBackgroundBrush"] = "#233145",
["HelpBadgeBorderBrush"] = "#3A4E6A", ["HelpBadgeBorderBrush"] = "#3A4E6A",
["ErrorTextBrush"] = "#FF8C8C", ["ErrorTextBrush"] = "#FF8C8C",
["SuccessTextBrush"] = "#6BD98A",
["WarningTextBrush"] = "#FFB357",
["PreviewBackgroundBrush"] = "#1B2636", ["PreviewBackgroundBrush"] = "#1B2636",
["PreviewBorderBrush"] = "#314359", ["PreviewBorderBrush"] = "#314359",
["DangerSoftBackgroundBrush"] = "#3A2024", ["DangerSoftBackgroundBrush"] = "#3A2024",
@ -382,6 +386,20 @@ public partial class MainWindowViewModel(
public string DrmVideoDurationMatchThresholdPercentLabel => public string DrmVideoDurationMatchThresholdPercentLabel =>
$"{Math.Round(DrmVideoDurationMatchThresholdPercent)}%"; $"{Math.Round(DrmVideoDurationMatchThresholdPercent)}%";
public string HideMissingCdmKeysWarningStatusText =>
_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing
? "(CDM keys missing)"
: "(CDM keys detected)";
public IBrush HideMissingCdmKeysWarningStatusBrush =>
ResolveThemeBrush(
_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing
? "WarningTextBrush"
: "SuccessTextBrush",
_startupResult.ClientIdBlobMissing || _startupResult.DevicePrivateKeyMissing
? "#FFB357"
: "#6BD98A");
public string SelectedUsersSummary => public string SelectedUsersSummary =>
$"{AvailableUsers.Count(user => user.IsSelected)} / {AvailableUsers.Count} selected"; $"{AvailableUsers.Count(user => user.IsSelected)} / {AvailableUsers.Count} selected";
@ -940,7 +958,10 @@ public partial class MainWindowViewModel(
private bool CanLogout() => IsAuthenticated && !IsDownloading; private bool CanLogout() => IsAuthenticated && !IsDownloading;
private bool CanEditConfig() => CurrentScreen != AppScreen.Config; private bool CanEditConfig() =>
CurrentScreen != AppScreen.Config &&
CurrentScreen != AppScreen.Loading &&
!IsDownloading;
partial void OnCurrentScreenChanged(AppScreen value) partial void OnCurrentScreenChanged(AppScreen value)
{ {
@ -1327,6 +1348,8 @@ public partial class MainWindowViewModel(
_startupResult = await startupService.ValidateEnvironmentAsync(); _startupResult = await startupService.ValidateEnvironmentAsync();
OnPropertyChanged(nameof(FfmpegVersion)); OnPropertyChanged(nameof(FfmpegVersion));
OnPropertyChanged(nameof(FfprobeVersion)); OnPropertyChanged(nameof(FfprobeVersion));
OnPropertyChanged(nameof(HideMissingCdmKeysWarningStatusText));
OnPropertyChanged(nameof(HideMissingCdmKeysWarningStatusBrush));
FfmpegPathError = string.Empty; FfmpegPathError = string.Empty;
FfprobePathError = string.Empty; FfprobePathError = string.Empty;
@ -1878,6 +1901,17 @@ public partial class MainWindowViewModel(
private static string EscapePathForConfig(string path) => private static string EscapePathForConfig(string path) =>
path.Replace(@"\", @"\\"); path.Replace(@"\", @"\\");
private static IBrush ResolveThemeBrush(string resourceKey, string fallbackColor)
{
if (Application.Current?.Resources.TryGetValue(resourceKey, out object? resource) == true &&
resource is IBrush brush)
{
return brush;
}
return new SolidColorBrush(Color.Parse(fallbackColor));
}
private static string BuildToolPathError(string propertyName, string? configuredPath, string toolName) private static string BuildToolPathError(string propertyName, string? configuredPath, string toolName)
{ {
string normalizedPath = NormalizePathForDisplay(configuredPath); string normalizedPath = NormalizePathForDisplay(configuredPath);

View File

@ -731,8 +731,16 @@
ToolTip.Tip="{Binding HelpText}" /> ToolTip.Tip="{Binding HelpText}" />
</Grid> </Grid>
<StackPanel Grid.Column="1" Spacing="4"> <StackPanel Grid.Column="1" Spacing="4">
<CheckBox IsVisible="{Binding IsBoolean}" <StackPanel IsVisible="{Binding IsBoolean}"
IsChecked="{Binding BoolValue}" /> Orientation="Horizontal"
Spacing="8">
<CheckBox IsChecked="{Binding BoolValue}"
VerticalAlignment="Center" />
<TextBlock IsVisible="{Binding IsHideMissingCdmKeysWarningField}"
VerticalAlignment="Center"
Text="{Binding ViewModel.HideMissingCdmKeysWarningStatusText, RelativeSource={RelativeSource AncestorType=views:MainWindow}, FallbackValue=''}"
Foreground="{Binding ViewModel.HideMissingCdmKeysWarningStatusBrush, RelativeSource={RelativeSource AncestorType=views:MainWindow}}" />
</StackPanel>
<ComboBox IsVisible="{Binding IsEnum}" <ComboBox IsVisible="{Binding IsEnum}"
HorizontalAlignment="Left" HorizontalAlignment="Left"
@ -1084,6 +1092,7 @@
<TextBlock Grid.Row="0" FontWeight="SemiBold" <TextBlock Grid.Row="0" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimaryBrush}" Text="Activity Log" /> Foreground="{DynamicResource TextPrimaryBrush}" Text="Activity Log" />
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
x:Name="ActivityLogListBox"
Margin="0,8,0,0" Margin="0,8,0,0"
ItemsSource="{Binding ActivityLog}" ItemsSource="{Binding ActivityLog}"
Background="{DynamicResource SurfaceBackgroundBrush}" Background="{DynamicResource SurfaceBackgroundBrush}"

View File

@ -1,9 +1,12 @@
using System.Diagnostics; using System.Diagnostics;
using System.Collections.Specialized;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using Avalonia.Threading;
using OF_DL.Gui.ViewModels; using OF_DL.Gui.ViewModels;
namespace OF_DL.Gui.Views; namespace OF_DL.Gui.Views;
@ -13,6 +16,10 @@ public partial class MainWindow : Window
private const string DiscordInviteUrl = "https://discord.com/invite/6bUW8EJ53j"; private const string DiscordInviteUrl = "https://discord.com/invite/6bUW8EJ53j";
private const string DocumentationUrl = "https://docs.ofdl.tools/"; private const string DocumentationUrl = "https://docs.ofdl.tools/";
private bool _hasInitialized; private bool _hasInitialized;
private bool _activityLogAutoScroll = true;
private bool _activityLogUserInteracted;
private bool _isActivityLogProgrammaticScroll;
private ScrollViewer? _activityLogScrollViewer;
public MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel; public MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel;
public MainWindow() public MainWindow()
@ -20,6 +27,7 @@ public partial class MainWindow : Window
InitializeComponent(); InitializeComponent();
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://OF DL.Gui/Assets/icon.ico"))); Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://OF DL.Gui/Assets/icon.ico")));
Opened += OnOpened; Opened += OnOpened;
Closed += OnClosed;
} }
private async void OnOpened(object? sender, EventArgs e) private async void OnOpened(object? sender, EventArgs e)
@ -30,12 +38,30 @@ public partial class MainWindow : Window
} }
_hasInitialized = true; _hasInitialized = true;
InitializeActivityLogAutoScroll();
if (DataContext is MainWindowViewModel vm) if (DataContext is MainWindowViewModel vm)
{ {
await vm.InitializeAsync(); await vm.InitializeAsync();
} }
} }
private void OnClosed(object? sender, EventArgs e)
{
if (ViewModel != null)
{
ViewModel.ActivityLog.CollectionChanged -= OnActivityLogCollectionChanged;
}
if (_activityLogScrollViewer != null)
{
_activityLogScrollViewer.ScrollChanged -= OnActivityLogScrollChanged;
}
ActivityLogListBox.PointerWheelChanged -= OnActivityLogPointerInteracted;
ActivityLogListBox.PointerPressed -= OnActivityLogPointerInteracted;
}
private async void OnBrowseFfmpegPathClick(object? sender, RoutedEventArgs e) private async void OnBrowseFfmpegPathClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not MainWindowViewModel vm) if (DataContext is not MainWindowViewModel vm)
@ -140,10 +166,7 @@ public partial class MainWindow : Window
private void OnFaqClick(object? sender, RoutedEventArgs e) private void OnFaqClick(object? sender, RoutedEventArgs e)
{ {
FaqWindow faqWindow = new() FaqWindow faqWindow = new() { WindowStartupLocation = WindowStartupLocation.CenterOwner };
{
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
faqWindow.Show(this); faqWindow.Show(this);
} }
@ -184,7 +207,7 @@ public partial class MainWindow : Window
} }
// Execute cancel command on any open modal // Execute cancel command on any open modal
vm.CreatorConfigEditor.ModalViewModel?.CancelCommand?.Execute(null); vm.CreatorConfigEditor.ModalViewModel.CancelCommand.Execute(null);
vm.CancelSinglePostOrMessageCommand.Execute(null); vm.CancelSinglePostOrMessageCommand.Execute(null);
vm.CancelMissingCdmWarningCommand.Execute(null); vm.CancelMissingCdmWarningCommand.Execute(null);
} }
@ -192,4 +215,69 @@ public partial class MainWindow : Window
private void OnModalContentClicked(object? sender, PointerPressedEventArgs e) => private void OnModalContentClicked(object? sender, PointerPressedEventArgs e) =>
// Stop the event from bubbling up to the overlay // Stop the event from bubbling up to the overlay
e.Handled = true; e.Handled = true;
private void InitializeActivityLogAutoScroll()
{
if (ViewModel != null)
{
ViewModel.ActivityLog.CollectionChanged -= OnActivityLogCollectionChanged;
ViewModel.ActivityLog.CollectionChanged += OnActivityLogCollectionChanged;
}
_activityLogScrollViewer = ActivityLogListBox.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
if (_activityLogScrollViewer != null)
{
_activityLogScrollViewer.ScrollChanged -= OnActivityLogScrollChanged;
_activityLogScrollViewer.ScrollChanged += OnActivityLogScrollChanged;
}
ActivityLogListBox.PointerWheelChanged -= OnActivityLogPointerInteracted;
ActivityLogListBox.PointerWheelChanged += OnActivityLogPointerInteracted;
ActivityLogListBox.PointerPressed -= OnActivityLogPointerInteracted;
ActivityLogListBox.PointerPressed += OnActivityLogPointerInteracted;
}
private void OnActivityLogCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (!_activityLogAutoScroll)
{
return;
}
Dispatcher.UIThread.Post(ScrollActivityLogToBottom);
}
private void OnActivityLogPointerInteracted(object? sender, PointerEventArgs e) => _activityLogUserInteracted = true;
private void OnActivityLogScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (_isActivityLogProgrammaticScroll || _activityLogScrollViewer == null || !_activityLogUserInteracted)
{
return;
}
_activityLogAutoScroll = IsAtBottom(_activityLogScrollViewer);
_activityLogUserInteracted = false;
}
private void ScrollActivityLogToBottom()
{
if (ViewModel == null || ViewModel.ActivityLog.Count == 0)
{
return;
}
_isActivityLogProgrammaticScroll = true;
try
{
ActivityLogListBox.ScrollIntoView(ViewModel.ActivityLog[^1]);
}
finally
{
_isActivityLogProgrammaticScroll = false;
}
}
private static bool IsAtBottom(ScrollViewer scrollViewer, double tolerance = 2) =>
scrollViewer.Offset.Y + scrollViewer.Viewport.Height >= scrollViewer.Extent.Height - tolerance;
} }