diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 5ecc2b0..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Build - -on: - push: - branches: [master, main] - pull_request: - branches: [master, main] - -permissions: - contents: read - -jobs: - build: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - - name: Setup MSBuild - uses: microsoft/setup-msbuild@v2 - - - name: Restore + Build solution - shell: pwsh - run: | - dotnet build Everything2Everything.slnx -c Release --nologo - - - name: Build MSIX - shell: pwsh - run: | - ./packaging/GenerateAssets.ps1 - ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 - - - name: Upload MSIX artifact - uses: actions/upload-artifact@v4 - with: - name: Everything2Everything-x64-msix-${{ github.sha }} - path: packaging/dist/Everything2Everything-x64.msix - retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f4fdbad..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: '릴리즈 태그 (예: v0.1.0)' - required: true - -permissions: - contents: write - -jobs: - build: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - - name: Setup MSBuild - uses: microsoft/setup-msbuild@v2 - - - name: Generate placeholder logos - shell: pwsh - run: ./packaging/GenerateAssets.ps1 - - - name: Decode PFX (if secret provided) - id: pfx - shell: pwsh - env: - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - run: | - if ($env:PFX_BASE64) { - $bytes = [Convert]::FromBase64String($env:PFX_BASE64) - $path = Join-Path $env:RUNNER_TEMP 'cert.pfx' - [IO.File]::WriteAllBytes($path, $bytes) - "pfx_path=$path" >> $env:GITHUB_OUTPUT - Write-Host '✅ PFX 디코딩 완료 — 서명 모드로 빌드' - } else { - Write-Host 'ℹ PFX_BASE64 secret 없음 — unsigned 모드로 빌드' - } - - - name: Build MSIX (signed if PFX, else unsigned) - shell: pwsh - env: - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} - run: | - $pfx = '${{ steps.pfx.outputs.pfx_path }}' - if ($pfx) { - $sec = ConvertTo-SecureString -String $env:PFX_PASSWORD -AsPlainText -Force - ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 -Sign -PfxPath $pfx -PfxPassword $sec - } else { - ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 - } - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: Everything2Everything-x64-msix - path: packaging/dist/Everything2Everything-x64.msix - - - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.event.inputs.tag || github.ref_name }} - files: packaging/dist/Everything2Everything-x64.msix - generate_release_notes: true - body: | - ## Everything2Everything - - 모든 파일을 우클릭 한 번으로 JPEG 로 변환합니다. - - ### 설치 방법 - 1. 아래 `Everything2Everything-x64.msix` 다운로드. - 2. 관리자 PowerShell: - ```powershell - # 자체 서명 인증서를 신뢰 저장소에 등록 (PFX 별도 보유 필요) - Import-PfxCertificate -CertStoreLocation Cert:\LocalMachine\TrustedPeople ` - -FilePath .\Everything2Everything-DevCert.pfx ` - -Password (ConvertTo-SecureString 'Everything2EverythingDev' -AsPlainText -Force) - - # MSIX 사이드로드 - Add-AppxPackage -Path .\Everything2Everything-x64.msix -ForceApplicationShutdown - ``` - 3. PNG/HEIC/PDF 등 파일 우클릭 → "JPEG로 빠른 변환" 또는 "JPEG로 변환…" - - > 이 패키지는 자체 서명입니다. 첫 PC 1회만 인증서 등록이 필요합니다. - - ### 변경사항 diff --git a/.gitignore b/.gitignore index 8a9fc76..124dfc0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ src/Everything2Everything.Shell/Everythi*/ # .NET artifacts dir from BuildMsix.ps1 artifacts/ +# test assets +test_assets/ + # NuGet packages restore + nuget.exe cache packages/ tools/* diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest index e43af0e..0aa68c7 100644 --- a/packaging/Package.appxmanifest +++ b/packaging/Package.appxmanifest @@ -1,6 +1,6 @@ - + Everything2Everything YunChan diff --git a/src/Everything2Everything.App/App.xaml.cs b/src/Everything2Everything.App/App.xaml.cs index 44b3cba..83bb4e0 100644 --- a/src/Everything2Everything.App/App.xaml.cs +++ b/src/Everything2Everything.App/App.xaml.cs @@ -76,15 +76,27 @@ public partial class App : Application private void ShowMainWindow() { - var window = new MainWindow(Engine, Settings); + RunFirstRunSetupIfNeeded(); + var adService = _services.GetService(); + var window = new MainWindow(Engine, Settings, null, adService); MainWindow = window; window.Show(); window.Activate(); } + /// 첫 실행이면 외부 도구 설치 마법사를 한 번 띄우고 완료 플래그를 남긴다(설정 창에서 재진입 가능). + private void RunFirstRunSetupIfNeeded() + { + if (Settings.Get("setup.tools.done") == "1") return; + var wizard = new ToolSetupWindow(); + wizard.ShowDialog(); + Settings.Set("setup.tools.done", "1"); + } + private void ShowConvertDialog(IReadOnlyList files) { - var window = new Views.MainWindow(Engine, Settings, files); + var adService = _services.GetService(); + var window = new Views.MainWindow(Engine, Settings, files, adService); MainWindow = window; window.Show(); window.Activate(); diff --git a/src/Everything2Everything.App/Everything2Everything.App.csproj b/src/Everything2Everything.App/Everything2Everything.App.csproj index 48e53ff..69062d0 100644 --- a/src/Everything2Everything.App/Everything2Everything.App.csproj +++ b/src/Everything2Everything.App/Everything2Everything.App.csproj @@ -1,7 +1,7 @@ - 1.0.11 + 1.0.18 WinExe net9.0-windows10.0.19041.0 enable diff --git a/src/Everything2Everything.App/ViewModels/AdViewModel.cs b/src/Everything2Everything.App/ViewModels/AdViewModel.cs new file mode 100644 index 0000000..b5f1b9c --- /dev/null +++ b/src/Everything2Everything.App/ViewModels/AdViewModel.cs @@ -0,0 +1,106 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using Everything2Everything.Core.Ads; + +namespace Everything2Everything.App.ViewModels; + +public class AdViewModel : INotifyPropertyChanged +{ + private readonly IAdService _adService; + private AdItem? _currentBannerAd; + private AdItem? _currentLargeAd; + private bool _isBannerVisible = true; + private bool _isLargeCardVisible = true; + + public event PropertyChangedEventHandler? PropertyChanged; + + public AdItem? CurrentBannerAd + { + get => _currentBannerAd; + set { _currentBannerAd = value; OnPropertyChanged(); } + } + + public AdItem? CurrentLargeAd + { + get => _currentLargeAd; + set { _currentLargeAd = value; OnPropertyChanged(); } + } + + public bool IsBannerVisible + { + get => _isBannerVisible; + set { _isBannerVisible = value; OnPropertyChanged(); } + } + + public bool IsLargeCardVisible + { + get => _isLargeCardVisible; + set { _isLargeCardVisible = value; OnPropertyChanged(); } + } + + public ICommand ClickBannerAdCommand { get; } + public ICommand ClickLargeAdCommand { get; } + public ICommand DismissBannerCommand { get; } + public ICommand DismissLargeCardCommand { get; } + + public AdViewModel(IAdService adService) + { + _adService = adService ?? throw new ArgumentNullException(nameof(adService)); + + CurrentBannerAd = _adService.GetNextBannerAd(); + CurrentLargeAd = _adService.GetNextLargeCardAd(); + + ClickBannerAdCommand = new AdCommand(_ => + { + if (CurrentBannerAd != null) + _adService.OpenAdUrl(CurrentBannerAd); + }); + + ClickLargeAdCommand = new AdCommand(_ => + { + if (CurrentLargeAd != null) + _adService.OpenAdUrl(CurrentLargeAd); + }); + + DismissBannerCommand = new AdCommand(_ => IsBannerVisible = false); + DismissLargeCardCommand = new AdCommand(_ => IsLargeCardVisible = false); + } + + public void RotateBannerAd() + { + CurrentBannerAd = _adService.GetNextBannerAd(); + } + + public void RotateLargeCardAd() + { + CurrentLargeAd = _adService.GetNextLargeCardAd(); + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private sealed class AdCommand : ICommand + { + private readonly Action _execute; + private readonly Func? _canExecute; + + public AdCommand(Action execute, Func? canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; + public void Execute(object? parameter) => _execute(parameter); + + public event EventHandler? CanExecuteChanged + { + add { } + remove { } + } + } +} diff --git a/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs index 5c2da02..9d3e5bf 100644 --- a/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs +++ b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs @@ -66,6 +66,13 @@ public partial class OptionsViewModel : ObservableObject [ObservableProperty] private int _channelsIndex; // 0=원본,1=모노,2=스테레오 [ObservableProperty] private bool _loudnorm; + // ── 상세 인코딩 옵션 (폴드아웃 Expander 상태 및 전문 규격) ──────────────────────────────── + [ObservableProperty] private bool _isAdvancedExpanded = true; // 사용자 피드백: 슬라이더/옵션을 바로 볼 수 있도록 기본 전개 + [ObservableProperty] private int _pdfCompressLevelIndex; // 0=Light, 1=Strong, 2=Max + [ObservableProperty] private int _pdfDpiIndex = 1; // 0=150, 1=200, 2=300 + [ObservableProperty] private bool _imageLossless; // WebP/PNG 무손실 + [ObservableProperty] private bool _progressive; // JPEG 프로그레시브 웹 로딩 + /// 현재 상태로 불변 ConvertOptions를 구성한다(기존 MainWindow.BuildOptions와 동일 동작 + 영상/오디오). public ConvertOptions ToConvertOptions() { @@ -77,15 +84,23 @@ public partial class OptionsViewModel : ObservableObject _ => "summarize", }; + var dpi = PdfDpiIndex switch + { + 0 => 150, + 2 => 300, + _ => 200, + }; + return new ConvertOptions { OnCollision = ConflictRule, OutputLocation = hasCustom ? OutputLocation.Custom : OutputLocation.SubfolderBesideSource, CustomOutputDirectory = hasCustom ? CustomOutputDirectory!.Trim() : null, KeepExifWhenPossible = !StripMetadata, - Jpeg = new JpegEncodingOptions { Quality = Quality }, - Webp = new WebpEncodingOptions { Quality = Quality }, + Jpeg = new JpegEncodingOptions { Quality = Quality, Progressive = Progressive }, + Webp = new WebpEncodingOptions { Quality = Quality, Lossless = ImageLossless }, Avif = new AvifEncodingOptions { Quality = Math.Clamp(Quality - 30, 1, 100) }, + PdfRender = new PdfRenderOptions { Dpi = dpi }, Ai = new AiOptions { Task = aiTask, diff --git a/src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs b/src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs new file mode 100644 index 0000000..40dea61 --- /dev/null +++ b/src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs @@ -0,0 +1,185 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Media; +using Everything2Everything.Core.Converters; + +namespace Everything2Everything.App.ViewModels; + +public enum ToolInstallState +{ + NotInstalled, + Installed, + Busy, + Failed, +} + +/// 설치 마법사 목록의 한 행. 카탈로그 정의 + 선택/상태/진행 표현. +public sealed class ToolInstallItem : INotifyPropertyChanged +{ + public ExternalToolDefinition Definition { get; } + public ToolInstallItem(ExternalToolDefinition definition) + { + Definition = definition; + RefreshStatus(); + } + + public string Name => Definition.DisplayName; + public string Description => Definition.Description; + + private bool _isSelected = true; + public bool IsSelected + { + get => _isSelected; + set { _isSelected = value; OnPropertyChanged(); } + } + + private ToolInstallState _state; + public ToolInstallState State + { + get => _state; + private set + { + _state = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsBusy)); + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(StatusBrush)); + } + } + + public string StatusText => State switch + { + ToolInstallState.Installed => "설치됨", + ToolInstallState.Busy => "설치 중…", + ToolInstallState.Failed => "실패", + _ => "미설치", + }; + + public bool IsBusy => State == ToolInstallState.Busy; + + public Brush StatusBrush => State switch + { + ToolInstallState.Installed => BrushOf("FsStatusSuccess"), + ToolInstallState.Busy => BrushOf("FsStatusInfo"), + ToolInstallState.Failed => BrushOf("FsStatusDanger"), + _ => BrushOf("FsStatusWarn"), + }; + + private string? _detail; + public string? Detail + { + get => _detail; + private set { _detail = value; OnPropertyChanged(); } + } + + public void RefreshStatus() + => State = Definition.IsInstalled() ? ToolInstallState.Installed : ToolInstallState.NotInstalled; + + public void ApplyResult(bool success, string? detail) + { + Detail = detail; + State = success ? ToolInstallState.Installed : ToolInstallState.Failed; + } + + public void BeginInstall() => State = ToolInstallState.Busy; + + private static Brush BrushOf(string key) + => (Application.Current?.TryFindResource(key) as Brush) + ?? _fallbacks.GetValueOrDefault(key, _warn); + + private static readonly Brush _info = Fixed(0x1565C0); + private static readonly Brush _success = Fixed(0x2E7D32); + private static readonly Brush _warn = Fixed(0xC77700); + private static readonly Brush _danger = Fixed(0xC62828); + private static readonly Dictionary _fallbacks = new() + { + ["FsStatusInfo"] = _info, + ["FsStatusSuccess"] = _success, + ["FsStatusWarn"] = _warn, + ["FsStatusDanger"] = _danger, + }; + + private static Brush Fixed(uint rgb) + { + var c = (int)rgb; + return new SolidColorBrush(Color.FromRgb((byte)(c >> 16), (byte)(c >> 8), (byte)c)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} + +/// 설치 마법사의 루트 뷰모델: 목록 + 전체 선택 + 선택분 순차 설치. +public sealed class ToolSetupViewModel : INotifyPropertyChanged +{ + private readonly ExternalToolInstaller _installer; + private bool _isInstalling; + private bool _selectAll = true; + + public ObservableCollection Tools { get; } = new(); + + public ToolSetupViewModel(IEnumerable? definitions = null, ExternalToolInstaller? installer = null) + { + _installer = installer ?? new ExternalToolInstaller(); + foreach (var def in definitions ?? ExternalToolCatalog.All) + Tools.Add(new ToolInstallItem(def)); + } + + public bool IsInstalling + { + get => _isInstalling; + private set { _isInstalling = value; OnPropertyChanged(); } + } + + public bool SelectAll + { + get => _selectAll; + set + { + _selectAll = value; + foreach (var t in Tools) t.IsSelected = value; + OnPropertyChanged(); + } + } + + public void RefreshStatus() + { + foreach (var t in Tools) t.RefreshStatus(); + } + + public async Task InstallSelectedAsync(CancellationToken ct) + { + if (IsInstalling) return; + IsInstalling = true; + try + { + // 이미 설치된 도구는 재설치하지 않고 건너뛴다(감지 상태가 Installed인 항목 제외). + foreach (var item in Tools.Where(t => t.IsSelected && t.State != ToolInstallState.Installed).ToList()) + await InstallOneAsync(item, ct).ConfigureAwait(true); + } + finally + { + IsInstalling = false; + } + } + + public async Task InstallOneAsync(ToolInstallItem item, CancellationToken ct) + { + item.BeginInstall(); + var result = await _installer.InstallAsync(item.Definition, ct).ConfigureAwait(true); + var detail = string.IsNullOrWhiteSpace(result.Output) + ? result.Message + : Tail(result.Output, 600); + item.ApplyResult(result.Success, detail); + } + + private static string Tail(string s, int max) + => s.Length <= max ? s : "…" + s[^max..]; + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/src/Everything2Everything.App/Views/AdBannerControl.xaml b/src/Everything2Everything.App/Views/AdBannerControl.xaml new file mode 100644 index 0000000..19d5b9c --- /dev/null +++ b/src/Everything2Everything.App/Views/AdBannerControl.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Everything2Everything.App/Views/AdBannerControl.xaml.cs b/src/Everything2Everything.App/Views/AdBannerControl.xaml.cs new file mode 100644 index 0000000..8791a0f --- /dev/null +++ b/src/Everything2Everything.App/Views/AdBannerControl.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace Everything2Everything.App.Views; + +public partial class AdBannerControl : UserControl +{ + public AdBannerControl() + { + InitializeComponent(); + } +} diff --git a/src/Everything2Everything.App/Views/AdLargeCardControl.xaml b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml new file mode 100644 index 0000000..63fce7e --- /dev/null +++ b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Everything2Everything.App/Views/AdLargeCardControl.xaml.cs b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml.cs new file mode 100644 index 0000000..9cd71c5 --- /dev/null +++ b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace Everything2Everything.App.Views; + +public partial class AdLargeCardControl : UserControl +{ + public AdLargeCardControl() + { + InitializeComponent(); + } +} diff --git a/src/Everything2Everything.App/Views/CategoryGlyphs.cs b/src/Everything2Everything.App/Views/CategoryGlyphs.cs index 2532e89..658ae97 100644 --- a/src/Everything2Everything.App/Views/CategoryGlyphs.cs +++ b/src/Everything2Everything.App/Views/CategoryGlyphs.cs @@ -50,7 +50,7 @@ public static class CategoryGlyphs public static ImageSource ForCategory(string category) { if (Cache.TryGetValue(category, out var cached)) return cached; - var uri = new Uri($"pack://application:,,,/Assets/glyph-{category}.png", UriKind.Absolute); + var uri = new Uri($"pack://application:,,,/Everything2Everything;component/Assets/glyph-{category}.png", UriKind.Absolute); var img = new BitmapImage(); img.BeginInit(); img.CacheOption = BitmapCacheOption.OnLoad; diff --git a/src/Everything2Everything.App/Views/DiagnoseWindow.xaml b/src/Everything2Everything.App/Views/DiagnoseWindow.xaml index 3e10c7a..848d52a 100644 --- a/src/Everything2Everything.App/Views/DiagnoseWindow.xaml +++ b/src/Everything2Everything.App/Views/DiagnoseWindow.xaml @@ -12,6 +12,7 @@ + diff --git a/src/Everything2Everything.App/Views/FormatShiftTheme.xaml b/src/Everything2Everything.App/Views/FormatShiftTheme.xaml index 41ada10..adce8d2 100644 --- a/src/Everything2Everything.App/Views/FormatShiftTheme.xaml +++ b/src/Everything2Everything.App/Views/FormatShiftTheme.xaml @@ -521,6 +521,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - @@ -724,8 +773,13 @@ - - + + + + + + + @@ -744,6 +798,28 @@ + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + - - + + + + + @@ -849,12 +941,12 @@ CornerRadius="10,0,0,10"/> - + - - - + + + @@ -880,7 +972,8 @@ + VerticalAlignment="Center" + TextTrimming="CharacterEllipsis"/> - + + HorizontalAlignment="Left" + VerticalAlignment="Center" + Margin="6,0,0,0"/> + + + + + - - - - - - - - - - + + + + + + + + + + + + + + - - - - - + + + + + + VerticalAlignment="Center" + TextTrimming="CharacterEllipsis"/> @@ -1083,13 +1230,17 @@ + + + - - + @@ -1100,8 +1251,8 @@ - - + @@ -1110,7 +1261,7 @@ Stroke="{StaticResource FsTextSecondary}" StrokeThickness="2" VerticalAlignment="Center" Data="M1,12 C1,12 5,4 12,4 C19,4 23,12 23,12 C23,12 19,20 12,20 C5,20 1,12 1,12 Z M12,9 A3,3 0 1,1 12,15 A3,3 0 1,1 12,9 Z"/> - @@ -1124,58 +1275,62 @@ - + - - - - - - - - + + + + + + + + + + + - - - - - + + + + + - - + + - - - - - - - + + + + + + + + @@ -1184,6 +1339,60 @@ BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + @@ -1269,16 +1486,30 @@ - - - - + Background="#D9090A0C" IsHitTestVisible="False"> + + + + + + + + + + + diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index 0b8d28c..0805fde 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -12,14 +12,37 @@ using Everything2Everything.App.Shell; using Everything2Everything.App.ViewModels; using Everything2Everything.Core; using Everything2Everything.Core.Filters; +using Everything2Everything.Core.Ads; using Everything2Everything.Core.Inspector; using Everything2Everything.Core.Presets; using LossClass = Everything2Everything.Core.Providers.LossClass; namespace Everything2Everything.App.Views; -public partial class MainWindow : Wpf.Ui.Controls.FluentWindow +public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + private bool _hasSelectedItem; + public bool HasSelectedItem + { + get => _hasSelectedItem; + set + { + if (_hasSelectedItem != value) + { + _hasSelectedItem = value; + OnPropertyChanged(); + } + } + } + + public ICommand ClearSearchCommand { get; } + public ICommand SelectAllQueueCommand => BatchSelectAllCommand; + public ICommand DeleteSelectedQueueCommand => BatchRemoveSelectedCommand; + private readonly ConversionEngine _engine; private readonly ISettingsStore _settings; private readonly OptionsViewModel _options = new(); @@ -112,15 +135,22 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow if (_selectedCategory != value) { _selectedCategory = value; + if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex != (int)value) + { + CategoryFilterCombo.SelectedIndex = (int)value; + } ApplyQueueFilters(); } } } - public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList? initialFiles = null) + public AdViewModel AdVm { get; } + + public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList? initialFiles = null, IAdService? adService = null) { _engine = engine; _settings = settings; + AdVm = new AdViewModel(adService ?? new AdService()); AddFilesCommand = new RelayCommand(_ => PickAndAddFiles()); ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()), @@ -145,7 +175,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow ToggleInspectorCommand = new RelayCommand(_ => ToggleInspector()); RemoveQueueItemCommand = new RelayCommand(p => RemoveQueueItem(p as QueueItem)); OpenFolderCommand = new RelayCommand(p => OpenFolderForPath(p as string)); - TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Past")); + TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Active")); ConflictRuleCommand = new RelayCommand(p => SetConflictRule(p as string)); CombineToggleCommand = new RelayCommand(_ => UpdateCombineState(SelectedOutputExtension)); OutputFormatChangedCommand = new RelayCommand(_ => OnOutputFormatSelected()); @@ -153,15 +183,34 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow DragLeaveCommand = new RelayCommand(_ => DropHintOverlay.Visibility = Visibility.Collapsed); DropCommand = new RelayCommand(p => HandleFilesDropped(p as DragEventArgs)); QueueRowCommand = new RelayCommand(p => HandleQueueRowClick(p as MouseButtonEventArgs)); + ClearSearchCommand = new RelayCommand(_ => + { + SearchText = ""; + if (SearchBox is not null) SearchBox.Text = ""; + }); PastRowCommand = new RelayCommand(p => HandlePastRowClick(p as MouseButtonEventArgs)); InitializeComponent(); + Title = "Everything2Everything"; + if (AppTitleBar is not null) AppTitleBar.Title = "Everything2Everything"; + + if (AdvancedOptionsExpander is not null) + { + AdvancedOptionsExpander.IsExpanded = true; + AdvancedOptionsExpander.Expanded += (_, _) => _options.IsAdvancedExpanded = true; + AdvancedOptionsExpander.Collapsed += (_, _) => _options.IsAdvancedExpanded = false; + } if (SmartPresetCombo is not null) { SmartPresetCombo.SelectionChanged += OnSmartPresetChanged; } + if (OutputFormatCombo is not null) + { + OutputFormatCombo.SelectionChanged += (_, _) => OnOutputFormatSelected(); + } + // ActiveQueueList/PastResultsList의 ItemsSource는 XAML이 ActiveQueue/PastResults에 바인딩(선언적). InitializeOutputFormats(); @@ -202,7 +251,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow return; } - CapabilityStatusText.Text = $"⚠ {notReady.Count}개 형식이 외부 도구를 기다립니다 (Diagnose 참조)"; + CapabilityStatusText.Text = $"⚠ {notReady.Count}개 형식이 외부 도구를 기다립니다 (진단 도구 참조)"; CapabilityStatusText.Visibility = Visibility.Visible; } @@ -288,7 +337,9 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow foreach (var path in paths) { if (!File.Exists(path) || existing.Contains(path)) continue; - _activeQueue.Add(QueueItem.FromPath(path)); + var item = QueueItem.FromPath(path); + item.PropertyChanged += OnQueueItemPropertyChanged; + _activeQueue.Add(item); } UpdateBadges(); UpdateProcessQueueButton(); @@ -302,9 +353,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow } } + private void OnQueueItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(QueueItem.IsSelected)) + { + UpdateQueueSummary(); + } + } + private void RemoveQueueItem(QueueItem? item) { if (item is null) return; + item.PropertyChanged -= OnQueueItemPropertyChanged; _activeQueue.Remove(item); UpdateBadges(); UpdateProcessQueueButton(); @@ -336,6 +396,40 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow var count = _pastResults.Sum(g => g.Entries.Count); TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture); UpdatePastResultsVisibility(); + UpdateQueueSummary(); + UpdatePastResultsTelemetry(); + } + + private void UpdateQueueSummary() + { + if (QueueSummaryCountText is null || QueueSummarySizeText is null) return; + var count = _activeQueue.Count; + long totalBytes = _activeQueue.Sum(q => q.SourceSizeBytes); + QueueSummaryCountText.Text = $"총 {count}개 항목"; + QueueSummarySizeText.Text = HumanizeBytes(totalBytes); + + var selectedCount = _activeQueue.Count(q => q.IsSelected); + if (QueueSelectedBadge is not null && QueueSelectedText is not null) + { + if (selectedCount > 0) + { + QueueSelectedBadge.Visibility = Visibility.Visible; + QueueSelectedText.Text = $"{selectedCount}개 선택됨"; + } + else + { + QueueSelectedBadge.Visibility = Visibility.Collapsed; + } + } + } + + private void UpdatePastResultsTelemetry() + { + if (PastTotalCountText is null || PastTotalSavingsText is null) return; + var totalCount = _pastResults.Sum(g => g.Entries.Count); + var totalSavings = _pastResults.Sum(g => g.SessionSavingsBytes); + PastTotalCountText.Text = $"{totalCount}개 파일"; + PastTotalSavingsText.Text = HumanizeBytes(totalSavings); } private void UpdateProcessQueueButton() @@ -343,22 +437,26 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow var count = _activeQueue.Count; if (_cts is not null) { - ProcessQueueButton.Content = $"변환 처리 중… ({count}개 파일)"; + ProcessQueueButton.Content = $"변환 처리 중… ({count}개)"; + ProcessQueueButton.ToolTip = "파일 변환이 진행 중입니다."; ProcessQueueButton.IsEnabled = false; } else if (count == 0) { - ProcessQueueButton.Content = "대기 중 — 파일을 드래그하여 추가하세요"; + ProcessQueueButton.Content = "파일을 드래그하여 추가"; + ProcessQueueButton.ToolTip = "대기열에 변환할 파일을 추가하세요 (단축키: Ctrl + O)"; ProcessQueueButton.IsEnabled = false; } else if (string.IsNullOrEmpty(SelectedOutputExtension)) { - ProcessQueueButton.Content = "변환 불가 (공통 형식 없음)"; + ProcessQueueButton.Content = "공통 형식 없음"; + ProcessQueueButton.ToolTip = "선택된 파일들 간에 호환 가능한 공통 출력 형식이 없습니다."; ProcessQueueButton.IsEnabled = false; } else { - ProcessQueueButton.Content = $"대기열 일괄 변환 시작 ({count}개) [Ctrl + Enter]"; + ProcessQueueButton.Content = $"변환 시작 ({count}개 파일)"; + ProcessQueueButton.ToolTip = $"대기열 일괄 변환 시작 ({count}개 파일) [단축키: Ctrl + Enter]"; ProcessQueueButton.IsEnabled = true; } } @@ -505,10 +603,44 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow var info = FileInspectorBuilder.Build(filePath); PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName; PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath; - PreviewFormatText.Text = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel; - PreviewSizeText.Text = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText; + var fmt = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel; + var sz = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText; + PreviewFormatText.Text = fmt; + PreviewSizeText.Text = sz; PreviewDimText.Text = info.DimensionsOrMeta; - PreviewPageText.Text = info.Category.ToString(); + PreviewPageText.Text = info.Category.ToKoreanLabel(); + UpdateConversionPipelineCard(fmt, sz); + HasSelectedItem = !string.IsNullOrEmpty(filePath); + } + + private void UpdateConversionPipelineCard(string? formatLabel, string? sizeText) + { + if (ConversionPipelineCard is null || PipelineSourceText is null || PipelineTargetText is null) return; + + if (string.IsNullOrEmpty(formatLabel) || formatLabel == "—") + { + PipelineSourceText.Text = "선택 대기"; + PipelineTargetText.Text = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant(); + if (PipelineLossText is not null) PipelineLossText.Text = "파일을 선택하면 변환 정보 표시"; + if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "대기 중"; + return; + } + + PipelineSourceText.Text = $"{formatLabel} · {sizeText ?? "—"}"; + var targetExt = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant(); + PipelineTargetText.Text = targetExt; + + var isSame = string.Equals(formatLabel, targetExt, StringComparison.OrdinalIgnoreCase); + if (isSame) + { + if (PipelineLossText is not null) PipelineLossText.Text = "무손실 재압축 및 메타 정돈"; + if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "최적화"; + } + else + { + if (PipelineLossText is not null) PipelineLossText.Text = $"{formatLabel} → {targetExt} 변환"; + if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "실시간 매칭"; + } } private void ShowPreviewLoading() @@ -704,6 +836,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow } } + private void OnCategoryFilterSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (CategoryFilterCombo?.SelectedItem is ComboBoxItem item && item.Tag is string tag) + { + ApplyFilterCategory(tag); + } + else if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex >= 0) + { + SelectedCategory = (FilterCategory)CategoryFilterCombo.SelectedIndex; + } + } + private void ApplyFilterCategory(string? categoryName) { if (Enum.TryParse(categoryName, true, out var cat)) @@ -733,6 +877,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow { bool select = parameter is true; BatchQueueService.SetSelectionAll(_activeQueue, select); + UpdateQueueSummary(); } private void BatchRemoveSelected() @@ -1043,10 +1188,9 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow private static string FormatDateLabel(DateOnly date) { var today = DateOnly.FromDateTime(DateTime.Today); - var label = date == today ? "Today" - : date == today.AddDays(-1) ? "Yesterday" - : date.ToString("dddd", CultureInfo.GetCultureInfo("en-US")); - return $"{label}, {date:MMM d}"; + if (date == today) return $"오늘 ({date:M월 d일})"; + if (date == today.AddDays(-1)) return $"어제 ({date:M월 d일})"; + return date.ToString("yyyy년 M월 d일 (ddd)", CultureInfo.GetCultureInfo("ko-KR")); } private void ApplyAppDataStats() @@ -1098,7 +1242,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow else if (TabPastBtn.IsChecked == true) { var confirm = MessageBox.Show(this, - "Past Results 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.", + "변환 기록 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.", "Everything2Everything", MessageBoxButton.OKCancel, MessageBoxImage.Question); if (confirm != MessageBoxResult.OK) return; @@ -1317,6 +1461,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow UpdateCombineState(keepExt); UpdateProcessQueueButton(); UpdateSmartPresetsForFormat(keepExt); + UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text); if (OutputFormatHint is not null) { @@ -1362,6 +1507,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow UpdateQualityPanelForFormat(ext); UpdateOutputDestHint(ext); UpdateSmartPresetsForFormat(ext); + UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text); } } @@ -1386,21 +1532,53 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow private void UpdateQualityPanelForFormat(string? extension) { - if (QualityPanel is null || QualityLabelText is null) return; var ext = extension?.ToLowerInvariant(); - var supportsQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif"; - QualityPanel.Visibility = supportsQuality ? Visibility.Visible : Visibility.Collapsed; - QualityLabelText.Text = ext switch + var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif"; + var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi"; + var isAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg" or ".flac" or ".wav"; + var isPdf = ext is ".pdf"; + + if (QualityPanel is not null) { - ".jpg" or ".jpeg" => "JPEG QUALITY", - ".webp" => "WEBP QUALITY", - ".avif" => "AVIF QUALITY", - _ => "ENCODING QUALITY", - }; + QualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed; + if (QualityLabelText is not null) + { + QualityLabelText.Text = ext switch + { + ".jpg" or ".jpeg" => "JPEG 압축 품질", + ".webp" => "WebP 압축 품질", + ".avif" => "AVIF 압축 품질", + _ => "압축 품질", + }; + } + } + + if (VideoQuickPanel is not null) + VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed; + if (AudioQuickPanel is not null) + AudioQuickPanel.Visibility = isAudio ? Visibility.Visible : Visibility.Collapsed; + if (PdfQuickPanel is not null) + PdfQuickPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed; + + var isText = ext is ".txt" or ".md"; + if (AiQuickPanel is not null) + AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed; + if (AiTargetLanguagePanel is not null && AiTaskQuickCombo is not null) + AiTargetLanguagePanel.Visibility = (isText && AiTaskQuickCombo.SelectedIndex == 1) + ? Visibility.Visible + : Visibility.Collapsed; UpdateMediaPanelForFormat(extension); } + private void OnAiTaskSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (AiTargetLanguagePanel is null || AiTaskQuickCombo is null) return; + AiTargetLanguagePanel.Visibility = AiTaskQuickCombo.SelectedIndex == 1 + ? Visibility.Visible + : Visibility.Collapsed; + } + /// 출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴). private void UpdateMediaPanelForFormat(string? extension) { @@ -1471,9 +1649,17 @@ public sealed class QueueItem : INotifyPropertyChanged public string StateText { get => _state; - set { _state = value; Raise(nameof(StateText)); Raise(nameof(IsDone)); } + set { _state = value; Raise(nameof(StateText)); Raise(nameof(DisplayStateText)); Raise(nameof(IsDone)); } } + /// 사용자에게 표시되는 정제된 한국어 상태 텍스트 (AGENTS.md Rule 3). + public string DisplayStateText => _state switch + { + "queued" => "대기 중", + "done" => "변환 완료", + _ => _state, + }; + public Brush StateBrush => _state switch { "queued" => (Application.Current?.TryFindResource("FsTextTertiary") as Brush) ?? Brushes.Gray, @@ -1547,7 +1733,7 @@ public sealed class DateGroup : INotifyPropertyChanged public string DateTitle { get; } public ObservableCollection Entries { get; } = new(); public long SessionSavingsBytes { get; set; } - public string SessionSavingsText => $"Session Savings: {MainWindow.HumanizeBytes(SessionSavingsBytes)}"; + public string SessionSavingsText => $"세션 절감: {MainWindow.HumanizeBytes(SessionSavingsBytes)}"; public DateGroup(string dateTitle) { DateTitle = dateTitle; } @@ -1591,7 +1777,7 @@ public sealed record HistoryRow( FormatLabel: label, FormatBrush: brush, FileName: Path.GetFileName(e.SourcePath), - MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount} output(s)", + MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount}개 파일", SizeText: MainWindow.HumanizeBytes(e.SourceSizeBytes), SavingsText: $"{arrow} {MainWindow.HumanizeBytes(Math.Abs(saved))}", SourcePath: e.SourcePath, diff --git a/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml b/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml index ba98883..25117b7 100644 --- a/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml +++ b/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml @@ -13,6 +13,7 @@ + @@ -73,6 +74,20 @@ + + + + + + + + + + +