UI 복잡 버전 234 RED→GREEN 완료(docs/UI_USECASES.md §A~§J): 라이브 테마 전환(DynamicResource 전환+ThemeResolver+HighContrast 테마, 재시작 없음), 디자인 토큰 체계·WCAG 대비 결함 5건 수정, 설정창 카테고리 내비/검색 재구성+AppConfig 40여 속성, 다운로드 관리자(속도/ETA/세그먼트맵/전체 일시정지-이어받기/CSV 내보내기/예약 게이트/중복 확인), 라이브러리·아카이브·작업공간·재생목록·즐겨찾기 편집 뷰, 접근성(자동화 속성 30여 개·커스텀 피어·docs/ACCESSIBILITY.md), 인터랙션(휠 클릭 새 탭·XButton 탐색·F12·영역 캡처·프로필 전환), 상태/알림(토스트·알림센터·온보딩·CrashReportDialog). 보강 테스트 35건+실창 E2E 4건(StaDispatcher 기반 — 프록시 설정이 기시작 핸들러를 건드리는 크래시 실결함 수정 포함) — 453/453 GREEN. 이전 미커밋 작업(CI 워크플로·Mdns·QueueHub·UpdateChecker·AGENTS/CLAUDE·빌드 스크립트) 일괄 포함

This commit is contained in:
Yun Chan 2026-08-17 15:54:53 +09:00
parent 5a0ce730b2
commit 17f2f91152
88 changed files with 9463 additions and 1127 deletions

View file

@ -1,7 +1,9 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
using VideoDownloader.App.Core;
using VideoDownloader.App.Services;
namespace VideoDownloader.App;
@ -10,33 +12,133 @@ public partial class App : Application
private static string CrashLogPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "crash.log");
// #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록해 조용히 삼켜지는 것을 방지.
// #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록 + UC-N18 크래시 리포트 다이얼로그 표시.
protected override void OnStartup(StartupEventArgs e)
{
DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); de.Handled = true; };
DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); CrashReportDialog.Show(de.Exception); de.Handled = true; };
AppDomain.CurrentDomain.UnhandledException += (_, ue) => LogCrash(ue.ExceptionObject as Exception);
TaskScheduler.UnobservedTaskException += (_, ue) => { LogCrash(ue.Exception); ue.SetObserved(); };
base.OnStartup(e);
ApplyTheme(ConfigStore.Load().Theme);
var config = ConfigStore.Load();
// UC-A04: OS 고대비 모드가 켜져 있고 별도 선택이 없으면 고대비 테마로 시작
if (SystemParameters.HighContrast && config.Theme is "dark" or "light" or "system")
config.Theme = "highcontrast";
ApplyTheme(config.Theme, config.CustomThemePath);
ApplyAccent(config.AccentColor);
ApplyFontSettings();
ApplyReduceMotion(config.ReduceMotion);
new MainWindow().Show();
}
/// <summary>테마 색(dark/light) + Shared(스타일) 리소스사전을 App.Resources 에 주입.
/// <summary>테마 색(dark/light/system/highcontrast/사용자파일) + Shared(스타일) 리소스사전을 App.Resources 에 주입.
/// 주의: 반드시 색(brush) 사전을 스타일 사전보다 먼저 로드해야 Shared 의 템플릿이
/// {StaticResource GlassBg} 등을 해석할 수 있다.</summary>
public static void ApplyTheme(string theme)
/// {DynamicResource GlassBg} 등을 해석할 수 있다. 인덱스 0 자리를 교체해 라이브 전환이 동작한다(UC-T04).</summary>
public static void ApplyTheme(string theme, string? customThemePath = null)
{
var (effective, customFile) = Services.ThemeResolver.Resolve(theme, customThemePath);
var dicts = Current.Resources.MergedDictionaries;
dicts.Clear();
var themeSrc = theme.Equals("light", StringComparison.OrdinalIgnoreCase)
? "pack://application:,,,/Themes/Light.xaml" : "pack://application:,,,/Themes/Dark.xaml";
// 어셈블리 한정 pack URI — 테스트 호스트(타 어셈블리 Application)에서도 App 의 테마를 찾는다.
var themeSrc = effective switch
{
"light" => "pack://application:,,,/VideoDownloader;component/Themes/Light.xaml",
"highcontrast" => "pack://application:,,,/VideoDownloader;component/Themes/HighContrast.xaml",
"custom" when customFile != null => customFile, // UC-T12 사용자 테마 파일(절대경로)
_ => "pack://application:,,,/VideoDownloader;component/Themes/Dark.xaml",
};
dicts.Add(new ResourceDictionary { Source = new Uri(themeSrc, UriKind.Absolute) });
dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Themes/Shared.xaml", UriKind.Absolute) });
dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/VideoDownloader;component/Themes/Shared.xaml", UriKind.Absolute) });
Current.Resources["EffectiveTheme"] = effective;
}
/// <summary>현재 적용된 테마가 라이트인지 (DWM 타이틀바 색 연동용).</summary>
public static bool IsLightTheme => ConfigStore.Load().Theme.Equals("light", StringComparison.OrdinalIgnoreCase);
/// <summary>현재 적용된 테마(dark/light/highcontrast/custom) — DWM 타이틀바·판정용.</summary>
public static string CurrentTheme => Current.Resources["EffectiveTheme"] as string ?? "dark";
/// <summary>현재 적용된 테마가 밝은 계열인지(DWM 타이틀바 색 연동용).</summary>
public static bool IsLightTheme => CurrentTheme is "light" or "custom";
/// <summary>UC-T08 사용자 강조색 적용 — 현재 테마 사전의 AccentBrush/Gradient/Fade/AccentText 를
/// 파생색으로 교체한다. DynamicResource 로 물린 모든 컨트롤(버튼·진행바)에 즉시 반영된다.</summary>
public static void ApplyAccent(string? hex)
{
if (string.IsNullOrWhiteSpace(hex) || !TryParseColor(hex, out var color)) return;
var dict = Current.Resources.MergedDictionaries.FirstOrDefault(d => d.Contains("AccentBrush"));
if (dict == null) return;
var base_ = Color.FromArgb(0xFF, color.R, color.G, color.B);
dict["AccentBrush"] = new SolidColorBrush(base_);
dict["AccentTextBrush"] = new SolidColorBrush(Lighten(base_, 0.12));
var grad = new LinearGradientBrush
{
StartPoint = new Point(0, 0),
EndPoint = new Point(1, 1),
GradientStops =
{
new GradientStop(base_, 0),
new GradientStop(Lighten(base_, 0.18), 1),
},
};
dict["AccentGradient"] = grad;
var fade = new LinearGradientBrush
{
StartPoint = new Point(0, 0),
EndPoint = new Point(0, 1),
GradientStops =
{
new GradientStop(Color.FromArgb(0x66, base_.R, base_.G, base_.B), 0),
new GradientStop(Color.FromArgb(0x00, base_.R, base_.G, base_.B), 1),
},
};
dict["AccentFadeBrush"] = fade;
}
/// <summary>UC-F16 폰트 설정 즉시 적용 — AppConfig 의 폰트/크기/확대 배율으로 UiFont·FontSize.Base 토큰 교체.</summary>
public static void ApplyFontSettings()
{
var c = ConfigStore.Load();
var stack = string.IsNullOrWhiteSpace(c.UiFontFamily)
? "Pretendard, Segoe UI Variable Text, Segoe UI"
: c.UiFontFamily.Trim();
var fallback = string.IsNullOrWhiteSpace(c.UiFontKoreanFallback) ? "Malgun Gothic" : c.UiFontKoreanFallback.Trim();
Current.Resources["UiFont"] = new FontFamily($"{stack}, {fallback}");
var scale = Math.Clamp(c.UiFontScale <= 0 ? 1.0 : c.UiFontScale, 0.5, 2.0); // UC-A13 최대 200%
var size = Math.Clamp(c.UiFontSize * scale, 9, 40);
Current.Resources["FontSize.Base"] = size;
Current.Resources["FontSize.Small"] = size - 1.5;
Current.Resources["FontSize.Large"] = size + 3;
Current.Resources["FontSize.XLarge"] = size + 9;
}
/// <summary>UC-A16/L21 애니메이션 축소 — Duration 토큰을 0으로 만들어 전환을 즉시 완결시킨다.</summary>
public static void ApplyReduceMotion(bool reduce)
{
Current.Resources["ReduceMotion"] = reduce;
Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.Zero);
Current.Resources["Duration.Normal"] = new System.Windows.Duration(reduce ? TimeSpan.Zero : TimeSpan.FromSeconds(0.3));
if (!reduce) Current.Resources["Duration.Fast"] = new System.Windows.Duration(TimeSpan.FromSeconds(0.15));
}
private static bool TryParseColor(string hex, out Color color)
{
hex = hex.Trim().TrimStart('#');
if (hex.Length == 6)
{
try
{
color = Color.FromRgb(
Convert.ToByte(hex[..2], 16), Convert.ToByte(hex[2..4], 16), Convert.ToByte(hex[4..6], 16));
return true;
}
catch { }
}
color = Colors.Transparent;
return false;
}
private static Color Lighten(Color c, double amount) => Color.FromRgb(
(byte)Math.Min(255, c.R + (255 - c.R) * amount),
(byte)Math.Min(255, c.G + (255 - c.G) * amount),
(byte)Math.Min(255, c.B + (255 - c.B) * amount));
private static void LogCrash(Exception? ex)
{

View file

@ -0,0 +1,47 @@
<Window x:Class="VideoDownloader.App.BookmarkManagerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="즐겨찾기 관리" Height="520" Width="640"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- UC-M18 즐겨찾기 검색 -->
<DockPanel Grid.Row="0" Margin="0,0,0,10">
<TextBlock Text="이름 또는 URL 검색" Foreground="{DynamicResource TextMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="SearchBox" Style="{StaticResource DarkTextBox}" TextChanged="SearchBox_TextChanged"/>
</DockPanel>
<ListView x:Name="List" Grid.Row="1" Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource TextPrimary}"
SelectionMode="Extended" KeyDown="List_KeyDown">
<ListView.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,3">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource CardActionButton}" Content="편집" Click="Edit_Click" Margin="4,0,0,0"/>
<Button Style="{StaticResource CardActionButton}" Content="삭제" Click="Delete_Click" Margin="4,0,0,0"/>
</StackPanel>
<StackPanel>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontWeight="Medium"/>
<TextBlock Text="{Binding Url}" Foreground="{DynamicResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DockPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<!-- UC-M19 Netscape 북마크 HTML 내보내기/가져오기 -->
<Button Style="{StaticResource GhostButton}" Content="HTML 내보내기" Click="Export_Click" Margin="0,0,8,0"/>
<Button Style="{StaticResource GhostButton}" Content="HTML 가져오기" Click="Import_Click" Margin="0,0,8,0"/>
<Button Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" Click="Close_Click"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,156 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using VideoDownloader.Browser;
namespace VideoDownloader.App;
/// <summary>UC-K16 즐겨찾기 관리 창 — 검색·편집(M17)·삭제·HTML 내보내기/가져오기(M19).</summary>
public partial class BookmarkManagerWindow : Window
{
private readonly ObservableCollection<Bookmark> _source;
private readonly Action _persist;
public BookmarkManagerWindow(ObservableCollection<Bookmark> bookmarks, Action persist)
{
InitializeComponent();
_source = bookmarks;
_persist = persist;
List.ItemsSource = _source;
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = SearchBox.Text?.Trim() ?? "";
var view = System.Windows.Data.CollectionViewSource.GetDefaultView(_source);
view.Filter = q.Length == 0
? null
: o => o is Bookmark b &&
(b.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
b.Url.Contains(q, StringComparison.OrdinalIgnoreCase));
}
private void Edit_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
var title = InputBox("즐겨찾기 편집", "이름", b.Title);
if (title == null) return;
var url = InputBox("즐겨찾기 편집", "URL", b.Url);
if (url == null) return;
var idx = _source.IndexOf(b);
if (idx >= 0) _source[idx] = new Bookmark(title.Trim(), url.Trim());
_persist();
}
private void Delete_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
_source.Remove(b);
_persist();
}
private void List_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Delete && List.SelectedItem is Bookmark b)
{
_source.Remove(b);
_persist();
e.Handled = true;
}
}
/// <summary>UC-M19 브라우저 호환 Netscape 북마크 HTML 로 내보내기.</summary>
private void Export_Click(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.SaveFileDialog { Filter = "북마크 HTML|*.html", FileName = "bookmarks.html" };
if (dlg.ShowDialog() != true) return;
var sb = new System.Text.StringBuilder();
sb.AppendLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
sb.AppendLine("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">");
sb.AppendLine("<TITLE>Bookmarks</TITLE><H1>Bookmarks</H1><DL><p>");
foreach (var b in _source)
sb.AppendLine($" <DT><A HREF=\"{System.Net.WebUtility.HtmlEncode(b.Url)}\">{System.Net.WebUtility.HtmlEncode(b.Title)}</A>");
sb.AppendLine("</DL><p>");
File.WriteAllText(dlg.FileName, sb.ToString());
}
/// <summary>Netscape HTML 에서 HREF/제목 쌍을 추출해 추가 가져오기.</summary>
private void Import_Click(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog { Filter = "북마크 HTML|*.html;*.htm" };
if (dlg.ShowDialog() != true) return;
try
{
var html = File.ReadAllText(dlg.FileName);
var added = 0;
foreach (var (url, title) in ParseNetscapeBookmarks(html))
{
if (_source.Any(b => b.Url.Equals(url, StringComparison.OrdinalIgnoreCase))) continue;
_source.Add(new Bookmark(title, url));
added++;
}
_persist();
MessageBox.Show(this, $"{added}개 항목을 가져왔습니다", "가져오기");
}
catch (Exception ex)
{
MessageBox.Show(this, $"가져오기 실패: {ex.Message}", "가져오기", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
internal static IEnumerable<(string Url, string Title)> ParseNetscapeBookmarks(string html)
{
var matches = System.Text.RegularExpressions.Regex.Matches(html,
@"<A\s[^>]*HREF=""(?<url>[^""]+)""[^>]*>(?<title>.*?)</A>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in matches)
{
var url = System.Net.WebUtility.HtmlDecode(m.Groups["url"].Value);
var title = System.Text.RegularExpressions.Regex.Replace(m.Groups["title"].Value, "<[^>]+>", "");
yield return (url, System.Net.WebUtility.HtmlDecode(title).Trim());
}
}
private string? InputBox(string title, string label, string initial)
{
var box = new TextBox { Text = initial, Style = (Style)FindResource("DarkTextBox"), Margin = new Thickness(0, 6, 0, 12) };
var win = new Window
{
Title = title,
Width = 440,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
Background = (System.Windows.Media.Brush)FindResource("WindowBg"),
};
var cancel = new Button { Content = "취소", Style = (Style)FindResource("GhostButton"), Width = 80, Margin = new Thickness(0, 0, 8, 0) };
var ok = new Button { Content = "확인", Style = (Style)FindResource("PrimaryButton"), Width = 80, IsDefault = true };
cancel.Click += (_, _) => win.DialogResult = false;
ok.Click += (_, _) => win.DialogResult = true;
win.Content = new StackPanel { Margin = new Thickness(16) }.Also(p =>
{
p.Children.Add(new TextBlock { Text = label, Foreground = (System.Windows.Media.Brush)FindResource("TextSecondary") });
p.Children.Add(box);
p.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, ok },
});
});
return win.ShowDialog() == true ? box.Text : null;
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}
/// <summary>람다식 객체 초기화 헬퍼(BookmarkManagerWindow 입력 다이얼로그용).</summary>
internal static class UiExtensions
{
public static T Also<T>(this T self, Action<T> init)
{
init(self);
return self;
}
}

View file

@ -100,6 +100,20 @@ public sealed class PageDownloadTracker
public void Resume(PageDownloadItem item) => item.Op.Resume();
public void Cancel(PageDownloadItem item) => item.Op.Cancel();
/// <summary>UC-D28 페이지 파일 다운로드 전체 일시정지.</summary>
public void PauseAll()
{
foreach (var item in Items.Where(i => i.CanControl).ToList())
try { item.Op.Pause(); } catch { }
}
/// <summary>UC-D28 짝 맞춤: 일시정지 항목 전체 재개(Interrupted 포함).</summary>
public void ResumeAll()
{
foreach (var item in Items.Where(i => !i.IsFinished).ToList())
try { item.Op.Resume(); } catch { }
}
public void ClearCompleted()
{
foreach (var done in Items.Where(i => i.IsFinished).ToList())

View file

@ -69,18 +69,22 @@ public sealed class ReaderMode
tab.IsReader = true;
tab.Title = string.IsNullOrWhiteSpace(title) ? tab.Title : title + " (리더)";
cv.NavigateToString(BuildReaderHtml(title ?? "", byline ?? "", excerpt ?? "", contentEl.GetString() ?? "", lightTheme));
cv.NavigateToString(BuildReaderHtml(title ?? "", byline ?? "", excerpt ?? "", contentEl.GetString() ?? "", lightTheme, AppFontStack));
return true;
}
/// <summary>호스트가 주입하는 앱 폰트 스택(UC-F17 — 폰트 하드코딩 제거). 기본값은 WPF 기본 스택.</summary>
public static string AppFontStack { get; set; } = "Segoe UI, sans-serif";
/// <summary>리더 HTML(Firefox 스타일 — 폰트/여백/테마). trusted-types 회피를 위해 iframe srcdoc 형태 대신 직접 문서.</summary>
public static string BuildReaderHtml(string title, string byline, string excerpt, string contentHtml, bool lightTheme)
public static string BuildReaderHtml(string title, string byline, string excerpt, string contentHtml, bool lightTheme, string? fontStack = null)
{
var bg = lightTheme ? "#faf8f5" : "#1b1c24";
var fg = lightTheme ? "#1b1c24" : "#e6e6ec";
var muted = lightTheme ? "#666" : "#9a9ab0";
var font = string.IsNullOrWhiteSpace(fontStack) ? AppFontStack : fontStack;
var css = $@"
body {{ background: {bg}; color: {fg}; font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; line-height: 1.75; }}
body {{ background: {bg}; color: {fg}; font-family: '{font.Replace("'", "")}', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; line-height: 1.75; }}
h1 {{ font-size: 1.7em; line-height: 1.3; margin-bottom: 6px; }}
.byline {{ color: {muted}; font-size: 0.9em; margin-bottom: 4px; }}
.excerpt {{ color: {muted}; font-style: italic; margin: 14px 0 26px; padding-left: 12px; border-left: 3px solid {muted}; }}
@ -92,6 +96,7 @@ blockquote {{ border-left: 3px solid {muted}; margin-left: 0; padding-left: 14px
var titleEsc = System.Net.WebUtility.HtmlEncode(title);
var bylineEsc = System.Net.WebUtility.HtmlEncode(byline);
var excerptEsc = System.Net.WebUtility.HtmlEncode(excerpt);
return $"""<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{titleEsc}</title><style>{css}</style></head><body><h1>{titleEsc}</h1>{(bylineEsc.Length > 0 ? $"<div class=\"byline\">{bylineEsc}</div>" : "")}{(excerptEsc.Length > 0 ? $"<p class=\"excerpt\">{excerptEsc}</p>" : "")}{contentHtml}</body></html>""";
// UC-A17: lang="ko" 선언으로 화면 낭독기가 한국어 음성으로 읽는다.
return $"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{titleEsc}</title><style>{css}</style></head><body><h1>{titleEsc}</h1>{(bylineEsc.Length > 0 ? $"<div class=\"byline\">{bylineEsc}</div>" : "")}{(excerptEsc.Length > 0 ? $"<p class=\"excerpt\">{excerptEsc}</p>" : "")}{contentHtml}</body></html>""";
}
}

View file

@ -74,6 +74,11 @@ public sealed class PlayerWindow : Window
{
var cur = _playlist.Current;
if (cur != null) _playlist.MarkPlayed(cur);
// UC-M12 반복 모드: one=현 트랙 재생, all=끝에서 처음으로 순환, none=끝에서 정지
var mode = _config.PlaylistLoopMode;
if (mode == "one") { PlayCurrent(); return; }
var atEnd = _playlist.Tracks.Count > 0 && _playlist.CurrentIndex >= _playlist.Tracks.Count - 1;
if (atEnd && mode != "all") return;
_playlist.Next();
PlayCurrent();
}));

View file

@ -13,3 +13,31 @@ public class InverseCountToVisibilityConverter : IValueConverter
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>다운로드 상태 문자열 → 표시 여부. parameter="done" 이면 완료일 때만 Visible(UC-A11 완료 아이콘).</summary>
public class StatusToVisibilityConverter : IValueConverter
{
public static readonly StatusToVisibilityConverter Done = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is string s && s == "완료" ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>UC-D10 세그먼트 진행도(0~1) → 블록 배경색(진행 중 강조/대기 트랙).</summary>
public class SegmentFillConverter : IValueConverter
{
public static readonly SegmentFillConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is double d && d >= 1.0
? System.Windows.Media.Brushes.DodgerBlue
: value is double p && p > 0
? (System.Windows.Media.Brush)System.Windows.Application.Current.FindResource("AccentBrush")
: (System.Windows.Media.Brush)System.Windows.Application.Current.FindResource("TrackBrush");
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}

View file

@ -47,6 +47,53 @@ public class DownloadManager
public ObservableCollection<DownloadItemViewModel> Items { get; }
/// <summary>다운로드 완료(§J 토스트 알림용).</summary>
public event Action<DownloadItemViewModel>? ItemCompleted;
/// <summary>다운로드 실패(§J 토스트·배너용).</summary>
public event Action<DownloadItemViewModel>? ItemFailed;
/// <summary>UC-D21 예약 게이트 — true 를 돌려줘야 다운로드가 시작된다(DownloadScheduler 연결).</summary>
public Func<bool>? DownloadGate { get; set; }
// ---- §E 속도/ETA 샘플러(항목별 최근 샘플) ----
private readonly Dictionary<string, (long Bytes, DateTime At)> _speedSamples = new();
private void ReportSpeed(DownloadItemViewModel item, DownloadProgress p)
{
if (p.ReceivedBytes < 0) return;
var now = DateTime.UtcNow;
if (_speedSamples.TryGetValue(item.Id, out var prev) && (now - prev.At).TotalMilliseconds < 400) return;
double bps = 0;
if (prev.Bytes > 0 && now > prev.At)
bps = Math.Max(0, (p.ReceivedBytes - prev.Bytes) / (now - prev.At).TotalSeconds);
_speedSamples[item.Id] = (p.ReceivedBytes, now);
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
item.UpdateSpeedEta(p.ReceivedBytes, p.TotalBytes, bps));
}
/// <summary>§E 세그먼트 맵 갱신 — 완료 세그먼트 수만큼 앞칸을 채운다.</summary>
private static void ReportSegmentMap(DownloadItemViewModel item, DownloadProgress p)
{
if (p.Total <= 0 || p.Stage != "downloading") return;
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
{
if (item.SegmentMap.Count != p.Total)
{
item.SegmentMap.Clear();
for (var i = 0; i < p.Total; i++) item.SegmentMap.Add(0);
}
for (var i = 0; i < Math.Min(p.Completed, item.SegmentMap.Count); i++)
if (item.SegmentMap[i] < 1.0) item.SegmentMap[i] = 1.0;
});
}
private void RaiseCompleted(DownloadItemViewModel item, string finalPath)
{
item.FinalFileName = Path.GetFileName(finalPath); // UC-D26 최종 저장 파일명
item.CompletedAt = DateTime.UtcNow; // UC-D08 자동 치움 판정용
ItemCompleted?.Invoke(item);
}
public DownloadManager(AppConfig config, Func<Uri, Task<List<CoreWebView2Cookie>>> getCookies, Action<string> log)
{
_config = config;
@ -59,8 +106,24 @@ public class DownloadManager
_reqLogPath = Path.Combine(config.OutputFolder, "requests.log");
try { Directory.CreateDirectory(config.OutputFolder); File.WriteAllText(_reqLogPath, ""); } catch { }
// UC-S13/D22 프록시 설정 — 공유 핸들러는 첫 요청 후 변경 불가(SocketsHttpHandler 제약).
// 프로세스 최초 1회만 반영하고, 해제는 앱 재시작으로 적용한다(설정창 RestartBanner 안내).
if (!string.IsNullOrWhiteSpace(config.ProxyServer) && Uri.TryCreate(config.ProxyServer, UriKind.Absolute, out var proxyUri))
{
lock (_gateLock)
{
if (System.Threading.Interlocked.Exchange(ref _proxyApplied, 1) == 0)
{
SharedHandler.Proxy = new System.Net.WebProxy(proxyUri);
SharedHandler.UseProxy = true;
}
}
}
}
private static int _proxyApplied;
public void AppendAll(string line)
{
try
@ -184,6 +247,15 @@ public class DownloadManager
HttpClient? http = null;
try
{
// UC-D21 예약 창 밖이면 창이 열릴 때까지 대기(30초 폴링, 취소 가능)
while (DownloadGate != null && !DownloadGate())
{
item.Status = "예약 대기";
try { await Task.Delay(TimeSpan.FromSeconds(30), item.Cts?.Token ?? CancellationToken.None); }
catch (OperationCanceledException) { }
if (item.Cts?.IsCancellationRequested == true) { item.Status = "취소됨"; return; }
}
if (item.Status == "예약 대기") item.Status = "다운로드 중";
http = await BuildHttpClientAsync(item.MediaUrl, item.PageUrl);
if (item.Kind == MediaKind.Hls) await DownloadHlsAsync(item, http);
else if (item.Kind == MediaKind.Dash) await DownloadDashAsync(item, http);
@ -205,7 +277,7 @@ public class DownloadManager
}
}
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시.</summary>
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시. UC-D09 재시도 횟수 카운트.</summary>
public async Task RetryDownload(DownloadItemViewModel item)
{
if (item.Status is "다운로드 중" or "병합 중" or "리먹스 중") return;
@ -213,6 +285,8 @@ public class DownloadManager
item.Progress = 0;
item.Segments = "";
item.Error = null;
item.RetryCount++;
item.SegmentMap.Clear();
await StartDownload(item);
}
@ -223,6 +297,40 @@ public class DownloadManager
catch { }
}
// ---- §E 일시정지/재개: 엔진 취소 + 파트(.part/.parts) 보존 → 재개 시 이어받기 ----
/// <summary>항목 일시정지 — 진행 중 항목을 취소하되 임시 파일을 남겨 재개 시 이어받는다.</summary>
public void Pause(DownloadItemViewModel item)
{
if (item.Status is not ("다운로드 중" or "병합 중" or "리먹스 중")) return;
item.PausedForResume = true;
item.Status = "일시정지";
try { item.Cts?.Cancel(); } catch { }
}
/// <summary>일시정지 항목 재개(이어받기).</summary>
public void Resume(DownloadItemViewModel item)
{
if (!item.PausedForResume) return;
item.PausedForResume = false;
item.Started = false;
_ = StartDownload(item);
}
/// <summary>UC-D06 전체 일시정지.</summary>
public void PauseAll()
{
foreach (var it in Items.Where(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중").ToList())
Pause(it);
}
/// <summary>UC-D07 전체 재개.</summary>
public void ResumeAll()
{
foreach (var it in Items.Where(i => i.PausedForResume).ToList())
Resume(it);
}
/// <summary>소셜 카드 제목 갱신(페이지 타이틀이 나중에 확정될 때). 아직 시작 전이면 출력 파일명도 갱신.</summary>
public void RenameSocial(Uri url, string title)
{
@ -251,16 +359,18 @@ public class DownloadManager
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
finally
{
// #6 실패/취소 시 parts 정리 (성공 시엔 엔진이 정리)
if (item.Status != "완료")
// #6 실패/취소 시 parts 정리 (성공·일시정지 시엔 이어받기 위해 보존)
if (item.Status != "완료" && !item.PausedForResume)
{
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
catch { }
@ -285,17 +395,22 @@ public class DownloadManager
item.Progress = (int)Math.Round(p.OverridePercent);
}
else item.IsIndeterminate = true; // #9 길이 미상 → 부정확 모드
ReportSpeed(item, p); // §E 속도·ETA
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
finally
{
// #6/#12 임시 .part 정리
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
catch { }
// #6/#12 임시 .part 정리(일시정지 시 이어받기 위해 보존)
if (item.Status != "완료" && !item.PausedForResume)
{
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
catch { }
}
}
}
@ -312,15 +427,17 @@ public class DownloadManager
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
finally
{
if (item.Status != "완료")
if (item.Status != "완료" && !item.PausedForResume)
{
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
catch { }
@ -350,14 +467,16 @@ public class DownloadManager
item.IsIndeterminate = false;
item.Progress = (int)Math.Round(p.OverridePercent);
}
else item.IsIndeterminate = true; // 길이 미상/병환 단계 → 부정확 모드
else item.IsIndeterminate = true; // 길이 미상/변환 단계 → 부정확 모드
if (!string.IsNullOrEmpty(p.Detail)) item.Segments = p.Detail;
ReportSpeed(item, p); // §E 속도·ETA(yt-dlp 가 바이트 보고할 때)
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, fmt, cookiesPath, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
RaiseCompleted(item, final);
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
finally
{
// 잔류 임시/fragment 정리(성공 여부 무관). 무관 파일 미삭제를 위해 outputBase 접두로 한정.

View file

@ -0,0 +1,34 @@
<Window x:Class="VideoDownloader.App.CrashReportDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="예기치 않은 오류" Height="380" Width="560"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterScreen" ShowInTaskbar="False" Topmost="True">
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,12">
<TextBlock Text="&#xE7BA;" FontFamily="{DynamicResource IconFont}" FontSize="22"
Foreground="{DynamicResource ErrorBrush}" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="문제가 발생해 작업을 계속하지 못했습니다" Style="{StaticResource Type.Body}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- UC-N05 오류 상세 펼침 -->
<Expander Grid.Row="1" Header="상세 정보" Foreground="{DynamicResource TextSecondary}">
<TextBox x:Name="DetailBox" Style="{StaticResource DarkTextBox}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" FontFamily="{DynamicResource MonoFont}" FontSize="11"/>
</Expander>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Style="{StaticResource GhostButton}" Content="오류 복사" Click="Copy_Click" Margin="0,0,8,0"/>
<Button Style="{StaticResource PrimaryButton}" Content="닫기" Width="90" IsCancel="True" Click="Close_Click"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,34 @@
using System.Windows;
namespace VideoDownloader.App;
/// <summary>UC-N18 크래시 리포트 대화상자 — 처리되지 않은 예외의 요약·스택을 보여주고 복사를 제공한다.
/// App 글로벌 예외 핸들러가 기록(crash.log) 후 사용자에게 표시한다.</summary>
public partial class CrashReportDialog : Window
{
/// <summary>같은 예외 폭주로 다이얼로그가 연쇄 뜨는 것을 막는 재진입 가드.</summary>
public static bool IsOpen { get; private set; }
public CrashReportDialog(Exception ex)
{
InitializeComponent();
DetailBox.Text = $"{ex.GetType().Name}: {ex.Message}\r\n\r\n{ex.StackTrace}";
Closed += (_, _) => IsOpen = false;
}
/// <summary>비모달 표시(재진입 가드). 이미 떠 있으면 무시한다.</summary>
public static void Show(Exception ex)
{
if (IsOpen) return;
IsOpen = true;
try { new CrashReportDialog(ex).Show(); }
catch { IsOpen = false; }
}
private void Copy_Click(object sender, RoutedEventArgs e)
{
try { Clipboard.SetText(DetailBox.Text); } catch { }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Automation.Peers;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.Browser;
@ -17,6 +18,7 @@ using VideoDownloader.App.Core;
using VideoDownloader.Core.Detection;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Core.Config;
using Microsoft.Win32;
using Wv2 = Microsoft.Web.WebView2.Wpf;
using TabItem = VideoDownloader.Browser.Tabs.TabItem;
@ -105,7 +107,7 @@ public partial class MainWindow : Window
foreach (var b in BrowserStore.LoadBookmarks()) _bookmarks.Add(b);
foreach (var h in BrowserStore.LoadHistory()) _history.Add(h);
BookmarksList.ItemsSource = _bookmarks;
HistoryList.ItemsSource = _history;
HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰
_resolver = new SearchEngineResolver(_config.SearchEngine);
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
@ -123,8 +125,15 @@ public partial class MainWindow : Window
_playlist.Load(Path.Combine(AppDataDir, "playlist.json"));
BuildPalette();
// UI 복잡 버전 유즈케이스(§A~§J) 초기화 — MainWindow.Usecases.cs
InitializeUsecases();
// UC-L11/L12/L26 창 위치·크기·최대화·전체화면 복원
RestoreWindowBounds();
Loaded += async (_, _) => await InitializeBrowserAsync();
PreviewKeyDown += OnPreviewKeyDown;
PreviewMouseDown += Window_PreviewMouseDown; // UC-K05 마우스 제스처(XButton)·UC-K08 계열
StateChanged += OnWindowStateChanged;
AddressBox.LostKeyboardFocus += (_, _) => AddressPopup.IsOpen = false;
@ -211,7 +220,11 @@ public partial class MainWindow : Window
{
NewTabProvider = () => Task.FromResult(_tabs.NewTab(activate: false)),
};
_views.TabCoreReady += (_, cv) => cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지
_views.TabCoreReady += (tab, cv) =>
{
cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지
WireTabMouseGestures(cv); // UC-K06 휠 클릭 새 탭
};
_views.DownloadStarting += OnDownloadStarting;
_views.UrlChanged += OnUrlChanged;
_views.TitleChanged += OnTitleChanged;
@ -254,9 +267,17 @@ public partial class MainWindow : Window
// T3-14 실드 배지 갱신
_views.TrackerBlocked += UpdateShield;
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
_tabs = new TabManager(() => new TabItem(), _views.AttachView)
{
MaxTabs = _config.MaxTabs, // UC-B11 탭 상한
};
_tabs.ActiveChanged += OnActiveTabChanged;
_tabs.TabRemoved += OnTabRemoved;
_views.TabCoreReady += (tab, cv) =>
{
// UC-K15 새 탭 기본 줌 — 사이트별 저장 줌이 없을 때만 적용(T1-7 과 충돌 방지)
try { if (tab.View != null && _zoomStore.Count == 0 && Math.Abs(_config.DefaultZoomFactor - 1.0) > 0.01) tab.View.ZoomFactor = _config.DefaultZoomFactor; } catch { }
};
TabList.ItemsSource = _tabs.Tabs;
UpdatePrivateButton();
@ -266,6 +287,10 @@ public partial class MainWindow : Window
_tabs.RestoreSession(snap);
else
_tabs.NewTab(_config.HomeUrl);
// UC-B12 세션 복원을 잠자기로 — 비활성 탭 뷰를 접어 빠른 시작
if (_config.RestoreTabsSuspended && _tabs.ActiveTab != null)
foreach (var t in _tabs.Tabs.Where(t => !ReferenceEquals(t, _tabs.ActiveTab)).ToList())
try { if (t.View != null) t.View.Visibility = Visibility.Collapsed; } catch { }
UpdateNavState();
// T1-5 다운로드 속도 갱신 타이머
@ -294,6 +319,7 @@ public partial class MainWindow : Window
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
});
Log($"원격 접근 서버 포트 {_remoteServer.Port}");
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
}
catch { }
}
@ -345,7 +371,7 @@ public partial class MainWindow : Window
{
if (_views == null) return;
var byDomain = _views.TrackerCounter.ByDomain;
ShieldList.ItemsSource = byDomain.Count == 0
ShieldDomainStats.ItemsSource = byDomain.Count == 0
? new[] { "차단된 추적 요청이 없습니다" }
: byDomain.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Value,3} × {kv.Key}").ToList();
ShieldPopup.IsOpen = true;
@ -440,6 +466,9 @@ public partial class MainWindow : Window
try { tab.Core?.Resume(); } catch { }
tab.IsSuspended = false;
}
// UC-K14 현재 탭 줌 배율 표시 갱신
if (ZoomIndicator != null && tab.View != null)
ZoomIndicator.Content = $"{Math.Round(tab.View.ZoomFactor * 100)}%";
UpdateNavState();
}
@ -476,8 +505,8 @@ public partial class MainWindow : Window
private void Tab_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
// 휠 클릭(미들) → 탭 닫기 (브라우저 관례)
if (e.ChangedButton == MouseButton.Middle && e.ButtonState == MouseButtonState.Pressed)
// 휠 클릭(미들) → 탭 닫기 (브라우저 관례). MiddleButton 상태로 판정.
if (e.MiddleButton == MouseButtonState.Pressed)
{
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
{
@ -843,7 +872,14 @@ public partial class MainWindow : Window
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R) { ForceReload(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; }
else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; }
else if (e.Key == Key.F12) { OpenDevTools(); e.Handled = true; } // UC-K12
else if (e.Key == Key.Escape)
{
// UC-K10: 열린 팝업이 있으면 모두 닫고, 없으면 페이지 로딩 중지
if (AnyPopupOpen()) CloseAllPopups();
else try { cv?.Stop(); } catch { }
e.Handled = true;
}
else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Control)
@ -951,7 +987,7 @@ public partial class MainWindow : Window
}
// ---- 범용 우측 패널: 접기/펼치기 + 리사이즈 + 상태 저장 ----
private static double ClampPanelWidth(double w) => Math.Clamp(w, MinExpandedWidth, MaxExpandedWidth);
private double ClampPanelWidth(double w) => Math.Clamp(w, Math.Max(160, _config.RightPanelMinWidth), MaxExpandedWidth); // UC-L20 최소 폭 설정 반영
private void ApplyPanelState(bool collapsed)
{
@ -992,15 +1028,74 @@ public partial class MainWindow : Window
private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
// UC-K11: 다운로드 진행 중 종료 확인
var activeDownloads = _manager.Items.Count(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중" or "대기")
+ _pageDownloads.Items.Count(i => !i.IsFinished);
if (activeDownloads > 0 && !_forceClose)
{
e.Cancel = true;
var r = MessageBox.Show(this,
$"진행 중인 다운로드가 {activeDownloads}건 있습니다.\n그래도 종료하시겠습니까?",
"종료 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (r != MessageBoxResult.Yes) return;
_forceClose = true;
Close();
return;
}
_config.RightPanelCollapsed = _panelCollapsed;
if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value);
_config.CompactMode = _compactMode; // UC-L07
if (_splitTab != null)
_config.SplitViewRatio = Math.Clamp(SplitCol.Width.Value / Math.Max(1, SplitCol.Width.Value + TabHost.ActualWidth), 0.1, 0.9); // UC-L10
_config.FullScreen = _fullscreen; // UC-L26
_config.WindowMaximized = WindowState == WindowState.Maximized; // UC-L12
_config.WindowBounds = $"{Left},{Top},{Width},{Height}"; // UC-L11
ConfigStore.Save(_config);
// T1-10 세션 저장: 탭 URL/활성/핀 보존
try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10
try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0
System.Net.NetworkInformation.NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; // §J
try { Microsoft.Win32.SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; } catch { }
}
private bool _forceClose;
/// <summary>UC-L11/L12/L26: 저장된 창 상태(위치·크기·최대화) 복원.</summary>
private void RestoreWindowBounds()
{
try
{
if (!string.IsNullOrWhiteSpace(_config.WindowBounds))
{
var parts = _config.WindowBounds.Split(',');
if (parts.Length == 4 &&
double.TryParse(parts[0], out var l) && double.TryParse(parts[1], out var t) &&
double.TryParse(parts[2], out var w) && double.TryParse(parts[3], out var h))
{
// 화면 밖 좌표(모니터 분리 후) 방어 — 보이는 가상 화면 범위로 클램프
var bounds = SystemParameters.VirtualScreenLeft;
var boundsTop = SystemParameters.VirtualScreenTop;
var boundsW = SystemParameters.VirtualScreenWidth;
var boundsH = SystemParameters.VirtualScreenHeight;
if (l < bounds || t < boundsTop || l + w > bounds + boundsW || t + h > boundsTop + boundsH)
WindowStartupLocation = WindowStartupLocation.CenterScreen;
else { Left = l; Top = t; Width = Math.Max(600, w); Height = Math.Max(400, h); }
}
}
if (_config.WindowMaximized) WindowState = WindowState.Maximized;
}
catch { }
}
/// <summary>UC-K10 판정: 팝업 중 하나라도 열려 있는가.</summary>
private bool AnyPopupOpen() =>
BookmarksPopup.IsOpen || HistoryPopup.IsOpen || AddressPopup.IsOpen || NotesPopup.IsOpen ||
ShieldPopup.IsOpen || DownloadsPopup.IsOpen || TabSearchPopup.IsOpen || PalettePopup.IsOpen ||
PlaylistPopup.IsOpen || LinkPopup.IsOpen || LibraryPopup.IsOpen || ArchivePopup.IsOpen ||
ShortcutHelpPopup.IsOpen || NotificationCenterPopup.IsOpen || TabVolumePopup.IsOpen;
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
private void UpdatePrivateButton()
{
@ -1093,7 +1188,7 @@ public partial class MainWindow : Window
private void OpenHistory_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is HistoryEntry h)
if (sender is FrameworkElement fe && fe.DataContext is HistoryRow h) // UC-M20 묶음 뷰 행
{
HistoryPopup.IsOpen = false;
NavigateTo(h.Url);
@ -1114,6 +1209,7 @@ public partial class MainWindow : Window
_history.Insert(0, new HistoryEntry(Trunc(title, 80), url, DateTime.Now));
while (_history.Count > 500) _history.RemoveAt(_history.Count - 1);
BrowserStore.SaveHistory(_history.ToList());
HistoryList.ItemsSource = GroupHistoryByDate(_history); // UC-M20 날짜 묶음 뷰 유지
}
// ---- T2-8 탭 검색 (Ctrl+Shift+A) ----
@ -1188,7 +1284,7 @@ public partial class MainWindow : Window
var link = target?.LinkUri;
if (!string.IsNullOrEmpty(link))
{
var download = new MenuItem { Header = "이 링크의 영상 받기", FontWeight = FontWeights.Medium };
var download = new MenuItem { Header = "링크 다운로드", FontWeight = FontWeights.Medium }; // UC-K20
download.Click += (_, _) => QueueLinkForDownload(link);
menu.Items.Add(download);
@ -1289,6 +1385,15 @@ public partial class MainWindow : Window
() => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList());
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
_palette.RegisterSeparator();
// ---- UI 복잡 버전 유즈케이스 명령(docs/UI_USECASES.md) ----
_palette.Register("모두 일시정지", "pause all stop downloads 전체", PauseAllDownloads); // UC-K09
_palette.Register("모두 재개", "resume all downloads 전체", ResumeAllDownloads);
_palette.Register("라이브러리", "library files 받은 파일", () => LibraryBtn_Click(this, new RoutedEventArgs())); // UC-M01
_palette.Register("영역 캡처", "region capture screenshot 영역", async () => await RegionCapture()); // UC-K18
_palette.Register("프로필 전환", "profile switch session 세션", SwitchProfile); // UC-K13
_palette.Register("즐겨찾기 HTML 내보내기", "bookmark export html", () => ExportBookmarks()); // UC-M19
_palette.Register("단축키 목록", "shortcut help keys", () => ShortcutHelpPopup.IsOpen = true); // UC-K04
_palette.RegisterSeparator();
_palette.RegisterDynamic(() => _tabs.Tabs
.Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t)))
.ToList());
@ -1434,26 +1539,51 @@ public partial class MainWindow : Window
if (!_compactMode) _chromeRevealed = false;
}
private System.Windows.Threading.DispatcherTimer? _edgeRevealTimer;
private bool _edgePending;
protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e)
{
if (_compactMode)
{
var p = e.GetPosition(this);
var wantReveal = p.Y <= 4; // 상단 엣지 4px
if (wantReveal != _chromeRevealed)
// UC-L24 EdgeRevealDelayMs: 엣지에 머무는 동안만 복원(순간 지나침 무시)
if (wantReveal && !_chromeRevealed && !_edgePending)
{
_chromeRevealed = wantReveal;
ApplyChromeVisibility();
_edgePending = true;
_edgeRevealTimer ??= new System.Windows.Threading.DispatcherTimer();
_edgeRevealTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(0, _config.EdgeRevealDelayMs));
_edgeRevealTimer.Tick -= EdgeRevealTick;
_edgeRevealTimer.Tick += EdgeRevealTick;
_edgeRevealTimer.Stop();
_edgeRevealTimer.Start();
}
else if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
else if (!wantReveal)
{
_chromeRevealed = false;
ApplyChromeVisibility();
_edgePending = false;
_edgeRevealTimer?.Stop();
if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
{
_chromeRevealed = false;
ApplyChromeVisibility();
}
}
}
base.OnPreviewMouseMove(e);
}
private void EdgeRevealTick(object? sender, EventArgs e)
{
_edgeRevealTimer?.Stop();
_edgePending = false;
if (!_chromeRevealed)
{
_chromeRevealed = true;
ApplyChromeVisibility();
}
}
// ---- T2-9 스크린샷 ----
private async Task TakeScreenshot()
{
@ -1528,7 +1658,9 @@ public partial class MainWindow : Window
_splitTab = next;
SplitRightHost.Child = next.View;
next.View!.Visibility = Visibility.Visible;
SplitCol.Width = new GridLength(Math.Max(280, (TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth) / 2));
var total = TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth;
var ratio = _config.SplitViewRatio is > 0.1 and < 0.9 ? _config.SplitViewRatio : 0.5; // UC-L10 복원
SplitCol.Width = new GridLength(Math.Max(280, total * ratio));
SplitRightHost.Visibility = Visibility.Visible;
SplitSplitter.Visibility = Visibility.Visible;
Log($"분할 화면: {Trunc(next.Title, 30)}");
@ -1638,9 +1770,12 @@ public partial class MainWindow : Window
await WaitTabReadyLocal(tab);
var cv = tab.Core;
if (cv == null) return;
// UC-F18: 폰트 하드코딩 대신 앱 폰트 스택 주입(App.ApplyFontSettings 가 관리)
var fontStack = (FontFamily)Application.Current.FindResource("UiFont");
var fontCss = string.Join(",", fontStack.Source.Split(',').Select(f => $"'{f.Trim()}'"));
var tiles = _bookmarks.Take(18).Select(b =>
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>&#127760;</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI Variable Text','Segoe UI','Malgun Gothic',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
var dial = "<!DOCTYPE html><html lang='ko'><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:" + fontCss + ",sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
cv.NavigateToString(dial);
}
@ -1652,7 +1787,10 @@ public partial class MainWindow : Window
}
// ---- T2-11/T3-1 탭 컨텍스트 메뉴 + 워크스페이스 ----
private static readonly string[] GroupColors = { "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#e91e8c" };
// UC-B17: 그룹 색 팔레트를 테마 리소스(Shared.xaml GroupPalette)에서 가져온다.
private string[] GroupColors => (TryFindResource("GroupPalette") as Array)?
.OfType<SolidColorBrush>().Select(b => $"#{b.Color.R:X2}{b.Color.G:X2}{b.Color.B:X2}").ToArray()
?? Array.Empty<string>();
private void Tab_ContextMenu(object sender, MouseButtonEventArgs e)
{
@ -1672,34 +1810,88 @@ public partial class MainWindow : Window
mute.Click += (_, _) => { try { if (tab.Core != null) { tab.Core.IsMuted = !(tab.Core?.IsMuted == true); tab.IsMuted = tab.Core.IsMuted; } } catch { } };
menu.Items.Add(mute);
// UC-B23 탭 볼륨
var volume = new MenuItem { Header = "볼륨 조절" };
volume.Click += (_, _) => OpenTabVolume(tab);
menu.Items.Add(volume);
menu.Items.Add(new Separator());
// UC-B01 탭 잠금
var lockItem = new MenuItem { Header = tab.IsLocked ? "잠금 해제" : "탭 잠금(닫기 방지)" };
lockItem.Click += (_, _) => { tab.IsLocked = !tab.IsLocked; Log(tab.IsLocked ? "탭을 잠갔습니다" : "탭 잠금을 해제했습니다"); };
menu.Items.Add(lockItem);
// UC-B02 사용자 지정 탭 색
var color = new MenuItem { Header = "탭 색상" };
var noColor = new MenuItem { Header = "색 없음" };
noColor.Click += (_, _) => tab.CustomColor = null;
color.Items.Add(noColor);
foreach (var c in GroupColors)
{
var mi = new MenuItem { Header = c };
var swatch = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
swatch.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c));
mi.Icon = swatch;
mi.Click += (_, _) => tab.CustomColor = c;
color.Items.Add(mi);
}
menu.Items.Add(color);
var group = new MenuItem { Header = "그룹" };
var noGroup = new MenuItem { Header = "그룹 없음" };
noGroup.Click += (_, _) => _tabs.SetGroup(tab, null, null);
group.Items.Add(noGroup);
for (var i = 0; i < GroupColors.Length; i++)
{
var color = GroupColors[i];
var c = GroupColors[i];
var mi = new MenuItem { Header = $"그룹 {i + 1}" };
var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
sw.Fill = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(color));
sw.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString(c));
mi.Icon = sw;
mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", color);
mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", c);
group.Items.Add(mi);
}
menu.Items.Add(group);
if (tab.GroupName != null)
{
var rename = new MenuItem { Header = $"그룹 이름 바꾸기 ({tab.GroupName})" }; // UC-B04
rename.Click += (_, _) =>
{
var name = Prompt("그룹 이름 바꾸기", "새 그룹 이름", tab.GroupName!);
if (name != null && _tabs.RenameGroup(tab.GroupName!, name.Trim()))
Log($"그룹 이름 변경: {name.Trim()}");
};
menu.Items.Add(rename);
var collapse = new MenuItem { Header = tab.IsGroupCollapsed ? "그룹 펼치기" : "그룹 접기" }; // UC-B03
collapse.Click += (_, _) => _tabs.ToggleGroupCollapsed(tab.GroupName!);
menu.Items.Add(collapse);
var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" };
closeG.Click += (_, _) => _tabs.CloseGroup(tab.GroupName!);
closeG.Click += (_, _) =>
{
var n = _tabs.Tabs.Count(t => t.GroupName == tab.GroupName && !t.IsLocked);
// UC-B09 다중 닫기 확인
if (_config.ConfirmCloseMultipleTabs && n > 1 &&
MessageBox.Show(this, $"그룹 탭 {n}개를 닫을까요?", "탭 닫기", MessageBoxButton.OKCancel) != MessageBoxResult.OK)
return;
_tabs.CloseGroup(tab.GroupName!);
};
menu.Items.Add(closeG);
var bmG = new MenuItem { Header = "그룹 즐겨찾기에 저장" }; // UC-B24
bmG.Click += (_, _) => BookmarkGroup(tab.GroupName);
menu.Items.Add(bmG);
}
menu.Items.Add(new Separator());
var close = new MenuItem { Header = "탭 닫기" };
close.Click += (_, _) => _tabs.CloseTab(tab);
close.Click += (_, _) =>
{
if (!_tabs.CloseTab(tab)) Log("잠긴 탭은 닫을 수 없습니다 (탭 잠금 해제 후 시도)"); // UC-B01
};
menu.Items.Add(close);
menu.PlacementTarget = sender as UIElement;
@ -1739,6 +1931,7 @@ public partial class MainWindow : Window
_config.RemoteAccessEnabled = false;
ConfigStore.Save(_config);
Log("원격 접근을 껐습니다");
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
return;
}
@ -1760,8 +1953,10 @@ public partial class MainWindow : Window
Token = token,
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
EnableMdns = true, // MOBILE_PLAN §3: _videodl._tcp 발견 보조 (실패해도 서버는 뜬다)
});
Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}");
UpdateServerStatusIcon(); // §J 서버 상태 아이콘
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
pair.Show();
}
@ -1835,7 +2030,10 @@ public partial class MainWindow : Window
private void CardDownload_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
{
if (!ConfirmDuplicateDownload(it)) return; // UC-D25 중복 확인
_ = _manager.StartDownload(it);
}
}
// 소셜 카드: yt-dlp -J 로 사용 가능한 화질 조회 → 드롭다운 교체
@ -1903,12 +2101,14 @@ public partial class MainWindow : Window
ConfigStore.Save(_config);
_resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영
_manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영
if (w.ThemeChanged) RestartApp();
ApplyTheme(_config.Theme); // §A 라이브 테마(재시작 없음)
UpdateShortcutHelp(); // §H 단축키 재배치 반영
}
}
// 테마 변경 등은 리소스가 StaticResource 로 물려있어 런타임 전환이 어려움 → 재시작으로 적용.
private static void RestartApp()
/// <summary>테마 변경은 리소스를 DynamicResource 로 물려 런타임 전환이 가능해졌다(UC-T04).
/// 프록시·프로필 등 재시작이 필요한 변경은 설정창의 RestartBanner 가 안내한다.</summary>
internal static void RestartApp()
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe))
@ -1917,4 +2117,98 @@ public partial class MainWindow : Window
}
private static string Trunc(string s, int n) => string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s[..n] + "…");
// ==================== UI 유즈케이스 핸들러(소스 계약상 MainWindow.xaml.cs 배치) ====================
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
Dispatcher.BeginInvoke(new Action(() =>
{
// 고대비 켜/끄 → 설정 테마 무시하고 즉시 따라감(UC-A04)
if (SystemParameters.HighContrast && App.CurrentTheme != "highcontrast") ApplyTheme("highcontrast");
else if (!SystemParameters.HighContrast && App.CurrentTheme == "highcontrast") ApplyTheme(_config.Theme);
}));
}
private void CopyStatus_Click(object sender, MouseButtonEventArgs e)
{
try { Clipboard.SetText(StatusText.Text); _toast?.Show("상태 메시지를 복사했습니다", "info"); } catch { }
}
/// <summary>UC-K19 노트 편집 내용 저장.</summary>
private void NotesSave_Click(object sender, RoutedEventArgs e)
{
_notes.Save();
_toast?.Show("노트를 저장했습니다", "success");
}
/// <summary>UC-M17 즐겨찾기 편집(이름·URL).</summary>
private void EditBookmark_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
var title = Prompt("즐겨찾기 편집", "이름", b.Title);
if (title == null) return;
var url = Prompt("즐겨찾기 편집", "URL", b.Url);
if (url == null) return;
var idx = _bookmarks.IndexOf(b);
if (idx >= 0) _bookmarks[idx] = new Bookmark(title.Trim(), url.Trim(), b.Folder);
BrowserStore.SaveBookmarks(_bookmarks.ToList());
RefreshBookmarkBar();
}
/// <summary>UC-M16 즐겨찾기 폴더로 정리 — 항목을 지정 폴더로 이동.</summary>
private void BookmarkFolder_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not Bookmark b) return;
var folder = Prompt("폴더 지정", "폴더 이름(비우면 최상위)", b.Folder ?? "");
if (folder == null) return;
var idx = _bookmarks.IndexOf(b);
if (idx >= 0) _bookmarks[idx] = new Bookmark(b.Title, b.Url, string.IsNullOrWhiteSpace(folder) ? null : folder.Trim());
BrowserStore.SaveBookmarks(_bookmarks.ToList());
_toast?.Show($"'{Trunc(b.Title, 24)}' 를 '{(string.IsNullOrWhiteSpace(folder) ? "최상위" : folder.Trim())}' 로 이동", "info");
}
private void LibraryPlay_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) PlayLibraryEntry(entry);
}
/// <summary>UC-M8 삭제(휴지통 아님 — 확인 후 영구 삭제).</summary>
private void LibraryDelete_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not LibraryEntry entry) return;
if (MessageBox.Show(this, $"'{entry.Name}' 파일을 삭제할까요?", "라이브러리",
MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
try { File.Delete(entry.Path); } catch (Exception ex) { Log($"삭제 실패: {ex.Message}"); }
RefreshLibrary();
}
/// <summary>UC-M9 포함 폴더 열기(파일 선택 상태).</summary>
private void LibraryOpenFolder_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is LibraryEntry entry) OpenInExplorer(entry.Path);
}
/// <summary>UC-M13 재생목록 파일 내보내기(M3U).</summary>
private void PlaylistExport_Click(object sender, RoutedEventArgs e)
{
var dlg = new SaveFileDialog { Filter = "M3U 재생목록|*.m3u", FileName = "playlist.m3u" };
if (dlg.ShowDialog() != true) return;
var sb = new System.Text.StringBuilder("#EXTM3U\n");
foreach (var t in _playlist.Tracks)
sb.AppendLine($"#EXTINF:-1,{t.Title}\n{t.Url}");
File.WriteAllText(dlg.FileName, sb.ToString());
}
/// <summary>UC-A09: 메인 창 자동화 피어 — 창 이름에 현재 상태 메시지를 노출해 낭독기가 상태를 읽게 한다.</summary>
protected override AutomationPeer OnCreateAutomationPeer()
=> new MainWindowAutomationPeer(this);
private sealed class MainWindowAutomationPeer : WindowAutomationPeer
{
public MainWindowAutomationPeer(Window owner) : base(owner) { }
protected override string GetNameCore() => "Video Downloader 메인 창";
}
}

View file

@ -0,0 +1,57 @@
namespace VideoDownloader.App.Services;
/// <summary>
/// UC-D21 다운로드 예약 — 지정 시간대(예: 야간 02:00~08:00)에만 다운로드를 허용하는 게이트.
/// 시간 창은 "HH:mm-HH:mm" 형식. 자정을 가로지르는 창(22:00-06:00)도 지원한다.
/// </summary>
public sealed class DownloadScheduler
{
/// <summary>예약 창("HH:mm-HH:mm"). null/빈이면 항상 허용.</summary>
public string? Window { get; }
/// <summary>예약 창이 설정돼 있으면 true.</summary>
public bool Enabled => !string.IsNullOrWhiteSpace(Window);
public DownloadScheduler(string? window = null)
{
Window = string.IsNullOrWhiteSpace(window) ? null : window.Trim();
}
/// <summary>지정 시각에 다운로드가 허용되는지.</summary>
public bool CanRunNow(DateTime now)
{
if (!Enabled || TryParse(Window!, out var start, out var end) == false) return true;
var t = now.TimeOfDay;
return start <= end
? t >= start && t < end
: t >= start || t < end; // 자정 가로지름(예: 22:00-06:00)
}
/// <summary>다음 예약 창 시작까지 남은 시간. 항상 허용이면 Zero.</summary>
public TimeSpan TimeUntilNextWindow(DateTime now)
{
if (!Enabled || !TryParse(Window!, out var start, out _)) return TimeSpan.Zero;
var target = now.Date + start;
if (target <= now) target = target.AddDays(1);
return target - now;
}
/// <summary>"HH:mm-HH:mm" 파싱. 형식이 틀리면 false(게이트는 열린 채 둔다).</summary>
public static bool TryParse(string window, out TimeSpan start, out TimeSpan end)
{
start = end = TimeSpan.Zero;
var parts = (window ?? "").Split('-');
if (parts.Length != 2) return false;
return TryParseTime(parts[0], out start) && TryParseTime(parts[1], out end);
}
private static bool TryParseTime(string s, out TimeSpan ts)
{
ts = TimeSpan.Zero;
var p = s.Trim().Split(':');
if (p.Length != 2 || !int.TryParse(p[0], out var h) || !int.TryParse(p[1], out var m)) return false;
if (h is < 0 or > 23 || m is < 0 or > 59) return false;
ts = new TimeSpan(h, m, 0);
return true;
}
}

View file

@ -0,0 +1,78 @@
namespace VideoDownloader.App.Services;
/// <summary>단일 단축키 정의(명령 키 + 표시 조합).</summary>
public sealed record ShortcutBinding(string Command, string Gesture);
/// <summary>
/// UC-K02/K03 단축키 관리 — 기본 조합표 위에 사용자 재배치(AppConfig.Shortcuts)를 얹고,
/// 저장 전에 같은 조합을 쓰는 명령 충돌을 검증한다.
/// </summary>
public sealed class ShortcutManager
{
private readonly Dictionary<string, string> _user;
/// <summary>기본 단축키표(명령 키 → 기본 조합).</summary>
public static readonly IReadOnlyDictionary<string, string> Defaults = new Dictionary<string, string>
{
["newtab"] = "Ctrl+T",
["closetab"] = "Ctrl+W",
["reopentab"] = "Ctrl+Shift+T",
["find"] = "Ctrl+F",
["address"] = "Ctrl+L",
["downloads"] = "Ctrl+J",
["palette"] = "Ctrl+K",
["devtools"] = "F12",
["fullscreen"] = "F11",
["refresh"] = "F5",
["print"] = "Ctrl+P",
["pauseall"] = "Ctrl+Shift+P",
};
public ShortcutManager(IDictionary<string, string>? userShortcuts = null)
{
_user = new Dictionary<string, string>(userShortcuts ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase);
}
/// <summary>명령의 현재 조합(사용자 지정 우선, 없으면 기본값).</summary>
public string GestureFor(string command)
=> _user.TryGetValue(command, out var g) ? g : Defaults.GetValueOrDefault(command, "");
/// <summary>전체 목록(기본 전체 + 사용자 오버라이드 반영).</summary>
public List<ShortcutBinding> All()
=> Defaults.Keys
.Select(c => new ShortcutBinding(c, GestureFor(c)))
.Concat(_user.Where(kv => !Defaults.ContainsKey(kv.Key)).Select(kv => new ShortcutBinding(kv.Key, kv.Value)))
.ToList();
/// <summary>UC-K03 충돌 검증 — 같은 조합에 묶인 명령 그룹(2개 이상일 때만 반환).</summary>
public List<List<ShortcutBinding>> FindConflicts()
=> All()
.GroupBy(b => Normalize(b.Gesture))
.Where(g => g.Count() > 1 && g.Key.Length > 0)
.Select(g => g.ToList())
.ToList();
/// <summary>조합 문자열 파싱("Ctrl+Shift+P" → ModifierKeys+Key). 파싱 실패 시 null.</summary>
public static (System.Windows.Input.ModifierKeys Mods, System.Windows.Input.Key Key)? Parse(string gesture)
{
if (string.IsNullOrWhiteSpace(gesture)) return null;
var mods = System.Windows.Input.ModifierKeys.None;
var key = System.Windows.Input.Key.None;
foreach (var part in gesture.Split('+'))
{
switch (part.Trim().ToLowerInvariant())
{
case "ctrl": mods |= System.Windows.Input.ModifierKeys.Control; continue;
case "shift": mods |= System.Windows.Input.ModifierKeys.Shift; continue;
case "alt": mods |= System.Windows.Input.ModifierKeys.Alt; continue;
case "win": mods |= System.Windows.Input.ModifierKeys.Windows; continue;
}
if (!Enum.TryParse(part.Trim(), ignoreCase: true, out key)) return null;
}
return key == System.Windows.Input.Key.None ? null : (mods, key);
}
/// <summary>조합 비교 정규화(대소문자·순서 무관).</summary>
public static string Normalize(string gesture)
=> string.Join("+", (gesture ?? "").Split('+').Select(p => p.Trim().ToLowerInvariant()).OrderBy(p => p));
}

View file

@ -0,0 +1,40 @@
using Microsoft.Win32;
namespace VideoDownloader.App.Services;
/// <summary>
/// UC-T13 시스템 테마 판별 — Windows 개인 설정(AppsUseLightTheme) 레지스트리를 읽어
/// dark/light 를 판정하고, 설정값(dark/light/system/highcontrast/파일경로)을 실제 적용할
/// 테마로 해석한다. 레지스트리 기반이라 WPF 스레드 없이도 단위 테스트가 가능하다.
/// </summary>
public static class ThemeResolver
{
/// <summary>설정 테마 값 → (테마 사전 이름, 사용자 정의 .xaml 경로|null).</summary>
public static (string Theme, string? CustomPath) Resolve(string setting, string? customThemePath = null)
{
var s = (setting ?? "dark").Trim().ToLowerInvariant();
return s switch
{
"system" => (IsSystemLight() ? "light" : "dark", null),
"highcontrast" => ("highcontrast", null),
"light" => ("light", null),
"dark" => ("dark", null),
// 커스텀 테마 파일 경로가 설정에 있으면 우선
_ when !string.IsNullOrWhiteSpace(customThemePath) && File.Exists(customThemePath) => ("custom", customThemePath),
_ => ("dark", null),
};
}
/// <summary>Windows 앱 모드가 라이트면 true(레지스트리 접근 실패 시 다크 취급).</summary>
public static bool IsSystemLight()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
if (key?.GetValue("AppsUseLightTheme") is int v) return v == 1;
}
catch { }
return false;
}
}

View file

@ -0,0 +1,114 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Threading;
namespace VideoDownloader.App.Services;
/// <summary>
/// UC-D11/UC-N02 토스트 알림 — 지정한 호스트 패널(우하단)에 카드를 띄우고
/// 설정된 시간 후 사라지게 한다. 방해 금지(DoNotDisturb)면 표시를 억제하고 알림센터에만 쌓는다.
/// UI 스레드 DispatcherTimer 기반이라 별도 스레드가 없다.
/// </summary>
public sealed class ToastNotifier
{
private readonly Panel _host;
private readonly DispatcherTimer _timer;
private readonly List<string> _history = new();
/// <summary>토스트 표시 시간. AppConfig.ToastDurationMs 와 연동.</summary>
public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(4);
/// <summary>UC-N15 방해 금지 — true 면 토스트를 띄우지 않고 기록만.</summary>
public bool DoNotDisturb { get; set; }
/// <summary>UC-N14 알림 센터용 최근 알림 이력(최신 우선, 상한 100).</summary>
public IReadOnlyList<string> History => _history.ToList();
public ToastNotifier(Panel host)
{
_host = host;
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_timer.Tick += (_, _) => Expire();
}
/// <summary>토스트 표시. kind 에 따라 아이콘·테두리 색이 달라진다(success/error/info).</summary>
public void Show(string message, string kind = "info")
{
_history.Insert(0, $"[{DateTime.Now:HH:mm}] {message}");
if (_history.Count > 100) _history.RemoveAt(_history.Count - 100);
if (DoNotDisturb) return;
var border = new Border
{
Background = (Brush)Application.Current.FindResource("CardBg"),
BorderBrush = kind switch
{
"success" => (Brush)Application.Current.FindResource("SuccessBrush"),
"error" => (Brush)Application.Current.FindResource("ErrorBrush"),
_ => (Brush)Application.Current.FindResource("AccentBrush"),
},
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(0, 8, 0, 0),
MaxWidth = 340,
Effect = new DropShadowEffect { Opacity = 0.4, BlurRadius = 16, ShadowDepth = 4 },
Tag = DateTime.UtcNow.Add(Duration),
};
var icon = kind switch
{
"success" => "\uE73E",
"error" => "\uE7BA",
_ => "\uE946",
};
var panel = new StackPanel { Orientation = Orientation.Horizontal };
panel.Children.Add(new TextBlock
{
Text = icon,
FontFamily = (FontFamily)Application.Current.FindResource("IconFont"),
FontSize = 15,
Foreground = border.BorderBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 9, 0),
});
panel.Children.Add(new TextBlock
{
Text = message,
Foreground = (Brush)Application.Current.FindResource("TextPrimary"),
FontSize = 12.5,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center,
});
border.Child = panel;
border.MouseLeftButtonUp += (_, _) => Collapse(border);
border.Opacity = 0;
_host.Children.Add(border);
border.Loaded += (_, _) =>
{
var anim = new DoubleAnimation(0, 1, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast"));
border.BeginAnimation(UIElement.OpacityProperty, anim);
};
if (!_timer.IsEnabled) _timer.Start();
}
private void Expire()
{
var now = DateTime.UtcNow;
foreach (var child in _host.Children.OfType<Border>().ToList())
{
if (child.Tag is DateTime until && now >= until) Collapse(child);
}
if (_host.Children.Count == 0) _timer.Stop();
}
private void Collapse(Border border)
{
var anim = new DoubleAnimation(1, 0, (System.Windows.Duration)Application.Current.FindResource("Duration.Fast"));
anim.Completed += (_, _) => _host.Children.Remove(border);
border.BeginAnimation(UIElement.OpacityProperty, anim);
}
}

View file

@ -1,11 +1,14 @@
<Window x:Class="VideoDownloader.App.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="설정" Height="600" Width="620"
FontFamily="{StaticResource UiFont}"
Background="{StaticResource WindowBgGradient}"
Title="설정" Height="680" Width="860"
FontFamily="{DynamicResource UiFont}"
Background="{DynamicResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ResizeMode="CanResize" ShowInTaskbar="False"
WindowStyle="None">
UseLayoutRounding="True"
KeyboardNavigation.TabNavigation="Cycle"
WindowStyle="None"
AutomationProperties.Name="설정 창">
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeight="0"
@ -23,153 +26,462 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 캡션 (드래그 핸들 + 닫기) -->
<Border Grid.Row="0" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
<!-- 캡션 (드래그 핸들 + 검색 + 닫기) -->
<Border Grid.Row="0" Background="{DynamicResource PanelBg}"
BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,0,0,1"
Padding="22,11,0,11" MouseLeftButtonDown="TitleBar_MouseDown">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource CloseButton}"
Content="&#xE8BB;" ToolTip="닫기" Click="Close_Click"/>
<TextBlock Text="설정" Foreground="{StaticResource TextPrimary}"
Content="&#xE8BB;" ToolTip="닫기" Click="Close_Click"
AutomationProperties.Name="설정 창 닫기"/>
<!-- UC-S01 설정 검색 -->
<TextBox x:Name="SettingsSearchBox" DockPanel.Dock="Right" Style="{StaticResource DarkTextBox}"
Width="220" FontSize="12" Padding="10,5" Margin="12,0,8,0"
TextChanged="SettingsSearchBox_TextChanged" ToolTip="설정 항목 검색"
AutomationProperties.Name="설정 검색"/>
<TextBlock Text="설정" Foreground="{DynamicResource TextPrimary}"
FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="22,18">
<StackPanel>
<Grid Grid.Row="1" Margin="18,14,18,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="170"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="외관" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<CheckBox Name="ThemeLightBox" Style="{StaticResource DarkCheckBox}"
Content="라이트 테마 사용 (적용 시 앱 재시작)" Margin="0,8,0,18"/>
<!-- UC-S02 좌측 카테고리 내비게이션 -->
<ListBox x:Name="SettingsNavList" Grid.Column="0" Background="Transparent" BorderThickness="0"
SelectionChanged="SettingsNav_SelectionChanged"
AutomationProperties.Name="설정 카테고리">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="12.5" Padding="10,7" Foreground="{DynamicResource TextSecondary}"/>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="10,7" Margin="0,1">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<TextBlock Text="저장 폴더" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,18">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFolder"/>
<TextBox Name="FolderBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto" Padding="14,0,4,0">
<StackPanel>
<TextBlock Text="홈 페이지 (홈 버튼이 이동할 주소)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,18">
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<!-- ==================== 외관 ==================== -->
<StackPanel x:Name="SectionAppearance" Margin="0,0,0,20" Tag="외관">
<TextBlock Text="외관" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<StackPanel Margin="0,0,0,18">
<TextBlock Text="기본 검색 엔진 (주소창에 검색어 입력 시)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<ComboBox Name="SearchEngineBox" Style="{StaticResource DarkComboBox}" Margin="0,6,0,4"
SelectionChanged="SearchEngineBox_SelectionChanged"/>
<TextBlock x:Name="SearchEngineHint" FontSize="11" Foreground="{StaticResource TextMuted}" TextWrapping="Wrap"
Text="주소창에 `g 검색어`, `yt 검색어` 등 키워드를 입력하면 검색 엔진을 즉시 전환할 수 있어요"/>
</StackPanel>
<!-- UC-T01/T02/T06 테마: 다크/라이트/시스템/고대비 콤보박스 — 즉시 적용 -->
<TextBlock Text="테마" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="ThemeModeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
Margin="0,4,0,8" SelectionChanged="ThemeModeBox_SelectionChanged"
AutomationProperties.Name="테마 선택" AutomationProperties.HelpText="선택 즉시 적용됩니다">
<ComboBoxItem Content="다크" Tag="dark"/>
<ComboBoxItem Content="라이트" Tag="light"/>
<ComboBoxItem Content="시스템 설정 따르기" Tag="system"/>
<ComboBoxItem Content="고대비" Tag="highcontrast"/>
</ComboBox>
<Grid Margin="0,0,0,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="세그먼트 동시 수" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="ConcurrencyBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
<!-- UC-T07 강조색 프리셋 스와치 + 사용자 지정 -->
<TextBlock Text="강조색" Style="{StaticResource Type.Caption}"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,4">
<RadioButton x:Name="AccentDefault" Style="{StaticResource DarkRadioButton}" Content="기본(보라)" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentBlue" Style="{StaticResource DarkRadioButton}" Content="파랑" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentGreen" Style="{StaticResource DarkRadioButton}" Content="초록" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
<RadioButton x:Name="AccentPink" Style="{StaticResource DarkRadioButton}" Content="핑크" Margin="0,0,12,0" Checked="Accent_Checked" GroupName="accent"/>
</StackPanel>
<DockPanel Margin="0,2,0,8">
<TextBlock DockPanel.Dock="Right" Text="#RRGGBB" Style="{StaticResource Type.Caption}" VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBox x:Name="AccentColorBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5"
ToolTip="사용자 지정 강조색(#6366F1 등)" AutomationProperties.Name="사용자 지정 강조색"/>
</DockPanel>
<!-- UC-T12 사용자 테마 파일 -->
<TextBlock Text="사용자 테마 파일(.xaml)" Style="{StaticResource Type.Caption}"/>
<DockPanel Margin="0,4,0,0">
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,0,4" Click="BrowseThemeFile"
AutomationProperties.Name="테마 파일 찾아보기"/>
<TextBox x:Name="CustomThemeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
<!-- UC-L02 밀도 -->
<TextBlock Text="밀도" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<ComboBox x:Name="UiDensityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
AutomationProperties.Name="정보 밀도 선택">
<ComboBoxItem Content="컴팩트" Tag="compact"/>
<ComboBoxItem Content="보통" Tag="normal"/>
<ComboBoxItem Content="여유" Tag="comfortable"/>
</ComboBox>
<!-- UC-L25/A13 배율 -->
<TextBlock Text="UI 크기 · 글자 확대 (100~200%)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<StackPanel Orientation="Horizontal">
<Slider x:Name="UiScaleSlider" Minimum="1.0" Maximum="2.0" Width="180" ValueChanged="UiScaleSlider_ValueChanged"
IsSnapToTickEnabled="True" TickFrequency="0.05"
AutomationProperties.Name="UI 크기 배율"/>
<TextBlock x:Name="UiScaleLabel" Text="100%" Style="{StaticResource Type.Mono}" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</StackPanel>
<!-- ==================== 글꼴 (UC-F13) ==================== -->
<StackPanel x:Name="SectionFont" Margin="0,0,0,20" Tag="글꼴">
<TextBlock Text="글꼴" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<TextBlock Text="기본 폰트" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="UiFontFamilyBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,8"
ToolTip="예: Pretendard, 나눔고딕" AutomationProperties.Name="기본 폰트"/>
<TextBlock Text="한국어 폴백 폰트" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="UiFontKoreanFallbackBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,8"
ToolTip="예: Malgun Gothic" AutomationProperties.Name="한국어 폴백 폰트"/>
<!-- UC-F15 폰트 크기 프리셋 -->
<TextBlock Text="폰트 크기" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="FontSizeBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag"
AutomationProperties.Name="폰트 크기 프리셋">
<ComboBoxItem Content="작게 (12)" Tag="12"/>
<ComboBoxItem Content="보통 (13)" Tag="13"/>
<ComboBoxItem Content="크게 (14)" Tag="14"/>
<ComboBoxItem Content="더 크게 (15)" Tag="15"/>
</ComboBox>
<!-- UC-F14 미리보기 -->
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Padding="12" Margin="0,10,0,0">
<StackPanel>
<TextBlock Text="미리보기" Style="{StaticResource Type.Caption}"/>
<TextBlock x:Name="FontPreviewText" Text="다운로드 완료 — 영상 123개 · 속도 4.5 MB/s"
FontSize="13" Foreground="{DynamicResource TextPrimary}" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="최대 동시 항목" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="MaxConcurrentBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
<!-- ==================== 일반 ==================== -->
<StackPanel x:Name="SectionGeneral" Margin="0,0,0,20" Tag="일반">
<TextBlock Text="일반" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<TextBlock Text="저장 폴더" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,10">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFolder"/>
<TextBox Name="FolderBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="홈 페이지 (홈 버튼이 이동할 주소)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,10">
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<!-- UC-S14 UI 언어 -->
<TextBlock Text="UI 언어" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="LanguageBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,10"
AutomationProperties.Name="UI 언어 선택">
<ComboBoxItem Content="한국어" Tag="ko"/>
<ComboBoxItem Content="English" Tag="en"/>
</ComboBox>
<StackPanel Margin="0,0,0,10">
<TextBlock Text="기본 검색 엔진 (주소창에 검색어 입력 시)" Style="{StaticResource Type.Caption}"/>
<ComboBox Name="SearchEngineBox" Style="{StaticResource DarkComboBox}" Margin="0,4,0,4"
SelectionChanged="SearchEngineBox_SelectionChanged"/>
<TextBlock x:Name="SearchEngineHint" Style="{StaticResource Type.Caption}"
Text="주소창에 `g 검색어`, `yt 검색어` 등 키워드를 입력하면 검색 엔진을 즉시 전환할 수 있어요"/>
</StackPanel>
<!-- UC-S15 Windows 시작 시 자동 실행 / UC-S16 트레이 최소화 -->
<WrapPanel Orientation="Horizontal" Margin="0,2,0,0">
<CheckBox Name="AutoStartBox" Style="{StaticResource DarkCheckBox}" Content="Windows 시작 시 자동 실행" Margin="4,4"/>
<CheckBox Name="MinimizeToTrayBox" Style="{StaticResource DarkCheckBox}" Content="닫기 버튼으로 트레이로 최소화" Margin="4,4"/>
</WrapPanel>
</StackPanel>
<!-- ==================== 다운로드 ==================== -->
<StackPanel x:Name="SectionDownload" Margin="0,0,0,20" Tag="다운로드">
<TextBlock Text="다운로드" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="세그먼트 동시 수" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="ConcurrencyBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="최대 동시 항목" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="MaxConcurrentBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<!-- UC-S11 재시도 횟수 -->
<StackPanel Grid.Column="4">
<TextBlock Text="재시도 횟수" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="RetriesBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
</Grid>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="최소 크기 (MB)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="MinSizeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<!-- UC-D22 속도 상한 -->
<StackPanel Grid.Column="2">
<TextBlock Text="속도 상한 (KB/s, 0=무제한)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="MaxSpeedBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
</Grid>
<WrapPanel Orientation="Horizontal" Margin="0,2,0,10">
<CheckBox Name="AutoClearCompletedBox" Style="{StaticResource DarkCheckBox}" Content="완료 항목 자동 치우기" Margin="4,4"/>
<CheckBox Name="OpenFolderOnCompleteBox" Style="{StaticResource DarkCheckBox}" Content="완료 시 폴더 자동 열기" Margin="4,4"/>
</WrapPanel>
<!-- UC-D21 다운로드 예약 창 -->
<TextBlock Text="다운로드 예약 시간대 (HH:mm-HH:mm, 비우면 항상)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<TextBox x:Name="ScheduleBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5" Width="200" HorizontalAlignment="Left"
ToolTip="예: 02:00-08:00 — 이 시간대에만 다운로드가 실행됩니다" AutomationProperties.Name="다운로드 예약 시간대"/>
<!-- UC-D20 자막 언어 -->
<TextBlock Text="자막 언어 (ko/en/…, 비우면 자막 안 받음)" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<ComboBox x:Name="SubtitleLangBox" Style="{StaticResource DarkComboBox}" IsEditable="True" Margin="0,4,0,0"
AutomationProperties.Name="자막 언어 선택">
<ComboBoxItem Content="(자막 없음)" Tag=""/>
<ComboBoxItem Content="한국어 (ko)" Tag="ko"/>
<ComboBoxItem Content="English (en)" Tag="en"/>
<ComboBoxItem Content="日本語 (ja)" Tag="ja"/>
</ComboBox>
</StackPanel>
<!-- ==================== 알림 (UC-S17) ==================== -->
<StackPanel x:Name="SectionNotify" Margin="0,0,0,20" Tag="알림">
<TextBlock Text="알림" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Name="NotifySoundBox" Style="{StaticResource DarkCheckBox}" Content="완료 알림 소리" Margin="4,4"/>
<CheckBox Name="DoNotDisturbBox" Style="{StaticResource DarkCheckBox}" Content="방해 금지 (토스트 숨김)" Margin="4,4"/>
<CheckBox Name="PauseOnPowerSaverBox" Style="{StaticResource DarkCheckBox}" Content="절전 모드 진입 시 일시정지" Margin="4,4"/>
</WrapPanel>
<TextBlock Text="토스트 표시 시간 (초)" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="ToastDurationBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,5" Width="120" HorizontalAlignment="Left"
AutomationProperties.Name="토스트 표시 시간"/>
</StackPanel>
<!-- ==================== 접근성 (UC-A12) ==================== -->
<StackPanel x:Name="SectionAccessibility" Margin="0,0,0,20" Tag="접근성">
<TextBlock Text="접근성" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Name="ReduceMotionBox" Style="{StaticResource DarkCheckBox}" Content="애니메이션 줄이기" Margin="4,4"/>
<CheckBox Name="ShowCaptionsBox" Style="{StaticResource DarkCheckBox}" Content="영상 자막 표시" Margin="4,4"/>
<CheckBox Name="FocusSelectsAddressBox" Style="{StaticResource DarkCheckBox}" Content="주소창 포커스 시 전체 선택" Margin="4,4"/>
<CheckBox Name="PasteAndGoBox" Style="{StaticResource DarkCheckBox}" Content="붙여넣으면 즉시 이동" Margin="4,4"/>
</WrapPanel>
<TextBlock Text="글자 확대 배율 (저시력 지원, 외관 섹션 슬라이더와 연동)" Style="{StaticResource Type.Caption}"/>
<Slider x:Name="UiFontScaleSlider" Minimum="1.0" Maximum="2.0" Width="220" HorizontalAlignment="Left"
IsSnapToTickEnabled="True" TickFrequency="0.1" ValueChanged="UiFontScaleSlider_ValueChanged"
AutomationProperties.Name="글자 확대 배율"/>
</StackPanel>
<!-- ==================== 브라우저 ==================== -->
<StackPanel x:Name="SectionBrowser" Margin="0,0,0,20" Tag="브라우저">
<TextBlock Text="브라우저" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Name="TabWheelSwitchBox" Style="{StaticResource DarkCheckBox}" Content="휠로 탭 전환" Margin="4,4"/>
<CheckBox Name="ConfirmCloseMultipleTabsBox" Style="{StaticResource DarkCheckBox}" Content="여러 탭 닫기 전 확인" Margin="4,4"/>
<CheckBox Name="BookmarkBarVisibleBox" Style="{StaticResource DarkCheckBox}" Content="북마크바 표시" Margin="4,4"/>
</WrapPanel>
<!-- UC-L18 탭 스트립 위치 -->
<TextBlock Text="탭 스트립 위치" Style="{StaticResource Type.Caption}"/>
<ComboBox x:Name="TabStripPositionBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,8"
AutomationProperties.Name="탭 스트립 위치 선택">
<ComboBoxItem Content="위 (기본)" Tag="top"/>
<ComboBoxItem Content="아래" Tag="bottom"/>
<ComboBoxItem Content="왼쪽 (세로 탭)" Tag="left"/>
</ComboBox>
<TextBlock Text="반응할 형식" Style="{StaticResource Type.Caption}" Margin="0,4,0,6"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="FmtHls" Style="{StaticResource DarkCheckBox}" Content="HLS (m3u8)" Margin="4,5"/>
<CheckBox Name="FmtDash" Style="{StaticResource DarkCheckBox}" Content="DASH (.mpd)" Margin="4,5"/>
<CheckBox Name="FmtMp4" Style="{StaticResource DarkCheckBox}" Content="MP4" Margin="4,5"/>
<CheckBox Name="FmtWebM" Style="{StaticResource DarkCheckBox}" Content="WebM" Margin="4,5"/>
<CheckBox Name="FmtMkv" Style="{StaticResource DarkCheckBox}" Content="MKV" Margin="4,5"/>
<CheckBox Name="FmtM4v" Style="{StaticResource DarkCheckBox}" Content="M4V" Margin="4,5"/>
<CheckBox Name="FmtMov" Style="{StaticResource DarkCheckBox}" Content="MOV" Margin="4,5"/>
<CheckBox Name="FmtAvi" Style="{StaticResource DarkCheckBox}" Content="AVI" Margin="4,5"/>
<CheckBox Name="FmtAudio" Style="{StaticResource DarkCheckBox}" Content="오디오 (m4a/mp3/ogg...)" Margin="4,5"/>
</WrapPanel>
<TextBlock Text="브라우저 데이터" Style="{StaticResource Type.Caption}" Margin="0,10,0,4"/>
<TextBlock x:Name="ProfileModeText" Text="" Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,0">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="삭제" Padding="12,4" Margin="8,4,4,4"
ToolTip="쿠키·localStorage·캐시 삭제 (현재 프로필)" Click="ClearData_Click"/>
<TextBox x:Name="ProfilePathBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent" IsReadOnly="True"/>
</DockPanel>
</Border>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="최소 크기 (MB)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="MinSizeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<!-- ==================== 고급 (UC-S07 접힘) ==================== -->
<Expander x:Name="AdvancedSection" Header="고급" FontSize="14" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,20"
ExpandDirection="Down" IsExpanded="False" Tag="고급">
<StackPanel Margin="0,10,0,0">
<TextBlock Text="ffmpeg 경로" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFfmpeg"/>
<TextBox Name="FfmpegBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 시스템 PATH 의 ffmpeg 사용 (HLS→MP4 리먹스)" Style="{StaticResource Type.Caption}" Margin="0,0,0,10"/>
<!-- UC-S12 User-Agent / UC-S13 프록시 -->
<TextBlock Text="User-Agent" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="UserAgentBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,10"
AutomationProperties.Name="User-Agent"/>
<TextBlock Text="프록시 서버 (http://host:port)" Style="{StaticResource Type.Caption}"/>
<TextBox x:Name="ProxyBox" Style="{StaticResource DarkTextBox}" FontSize="12" Padding="8,6" Margin="0,4,0,4"
AutomationProperties.Name="프록시 서버"/>
<TextBlock Text="프록시·개인정보 보호 모드 변경은 앱 다시 시작 후 적용됩니다" Style="{StaticResource Type.Caption}"/>
<TextBlock Text="소셜 영상 (YouTube·Instagram·X)" Style="{StaticResource Type.Caption}" Margin="0,10,0,6"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="재설치" Padding="12,4" Margin="4,4,8,4" ToolTip="yt-dlp 최신 버전으로 다시 다운로드" Click="ReinstallYtDlp_Click"/>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="4,4,4,4" Click="BrowseYtDlp"/>
<TextBox Name="YtDlpBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 자동/PATH 의 yt-dlp 사용" Style="{StaticResource Type.Caption}" Margin="0,0,0,8"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,10">
<CheckBox Name="UseCookiesBox" Style="{StaticResource DarkCheckBox}" Content="로그인 필요 영상을 위해 브라우저 쿠키 사용" Margin="4,4"/>
<CheckBox Name="AutoInstallBox" Style="{StaticResource DarkCheckBox}" Content="yt-dlp 가 없으면 자동 다운로드" Margin="4,4"/>
<CheckBox Name="AutoUpdateBox" Style="{StaticResource DarkCheckBox}" Content="YouTube 차단 시 yt-dlp 자동 갱신 후 재시도" Margin="4,4"/>
<CheckBox Name="AutoInstallDenoBox" Style="{StaticResource DarkCheckBox}" Content="Deno(JS 런타임) 자동 설치 — YouTube 차단 해제용" Margin="4,4"/>
</WrapPanel>
<TextBlock Text="기본 화질" Style="{StaticResource Type.Caption}"/>
<ComboBox Name="DefaultQualityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,4,0,10">
<ComboBoxItem Content="최고 화질 (MP4)" Tag="best"/>
<ComboBoxItem Content="1080p" Tag="1080"/>
<ComboBoxItem Content="720p" Tag="720"/>
<ComboBoxItem Content="480p" Tag="480"/>
<ComboBoxItem Content="360p" Tag="360"/>
<ComboBoxItem Content="오디오만 (MP3)" Tag="audio"/>
</ComboBox>
<TextBlock Text="YouTube 클라이언트 (고급)" Style="{StaticResource Type.Caption}"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,4,0,0">
<TextBox Name="YoutubeClientsBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<TextBlock Text="비워두면 yt-dlp 기본값 사용(권장). 봇 차단/PO 토큰 오류 시 예: tv,android_vr,web" Style="{StaticResource Type.Caption}" Margin="0,4,0,0"/>
</StackPanel>
</Expander>
<!-- ==================== 단축키 (UC-K01) ==================== -->
<StackPanel x:Name="SectionShortcuts" Margin="0,0,0,20" Tag="단축키">
<TextBlock Text="단축키" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
<TextBlock Text="주요 단축키는 앱 안 Ctrl+K 명령 팔레트와 상태바 ? 버튼(단축키 목록)에서 확인하고, 충돌 없이 직접 바꾸려면 JSON 파일로 관리합니다." Style="{StaticResource Type.Caption}"/>
</StackPanel>
</Grid>
<TextBlock Text="ffmpeg 경로" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFfmpeg"/>
<TextBox Name="FfmpegBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 시스템 PATH 의 ffmpeg 사용 (HLS→MP4 리먹스)" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,18"/>
<!-- ==================== 실험적 (UC-S18) ==================== -->
<StackPanel x:Name="SectionExperimental" Margin="0,0,0,20" Tag="실험적">
<TextBlock Text="실험적" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="RestoreTabsSuspendedBox" Style="{StaticResource DarkCheckBox}" Content="세션 복원을 잠자기 상태로(빠른 시작)" Margin="4,4"/>
<CheckBox Name="TabTitleMultilineBox" Style="{StaticResource DarkCheckBox}" Content="탭 제목 두 줄 표시" Margin="4,4"/>
</WrapPanel>
</StackPanel>
<TextBlock Text="소셜 영상 (YouTube·Instagram·X)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<TextBlock Text="yt-dlp 로 소셜 사이트 영상을 받습니다" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,2,0,8"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="재설치" Padding="12,4" Margin="4,4,8,4" ToolTip="yt-dlp 최신 버전으로 다시 다운로드" Click="ReinstallYtDlp_Click"/>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="4,4,4,4" Click="BrowseYtDlp"/>
<TextBox Name="YtDlpBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 자동/PATH 의 yt-dlp 사용" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,10"/>
<!-- ==================== 업데이트·정보 ==================== -->
<StackPanel x:Name="SectionAbout" Margin="0,0,0,10" Tag="정보">
<TextBlock Text="앱 업데이트" Style="{StaticResource Type.H1}" FontSize="15" Margin="0,0,0,6"/>
<TextBlock Text="GitHub Releases 최신 태그와 비교합니다 (owner/repo 형식)" Style="{StaticResource Type.Caption}" Margin="0,0,0,6"/>
<Border Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="지금 확인" Padding="12,4" Margin="8,4,4,4"
ToolTip="GitHub Releases 에서 새 버전 확인" Click="CheckUpdate_Click"/>
<TextBox Name="UpdateRepoBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock x:Name="UpdateResultText" Text="" Style="{StaticResource Type.Caption}" Margin="0,0,0,14"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,14">
<CheckBox Name="UseCookiesBox" Style="{StaticResource DarkCheckBox}" Content="로그인 필요 영상을 위해 브라우저 쿠키 사용" Margin="4,4"/>
<CheckBox Name="AutoInstallBox" Style="{StaticResource DarkCheckBox}" Content="yt-dlp 가 없으면 자동 다운로드" Margin="4,4"/>
<CheckBox Name="AutoUpdateBox" Style="{StaticResource DarkCheckBox}" Content="YouTube 차단 시 yt-dlp 자동 갱신 후 재시도" Margin="4,4"/>
<CheckBox Name="AutoInstallDenoBox" Style="{StaticResource DarkCheckBox}" Content="Deno(JS 런타임) 자동 설치 — YouTube 차단 해제용" Margin="4,4"/>
</WrapPanel>
<!-- UC-S20 버전 정보 -->
<TextBlock Text="버전" Style="{StaticResource Type.Caption}"/>
<TextBlock x:Name="VersionText" Text="-" FontSize="12" Foreground="{DynamicResource TextSecondary}" Margin="0,2,0,0"/>
</StackPanel>
<StackPanel Margin="0,0,0,18">
<TextBlock Text="기본 화질" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<ComboBox Name="DefaultQualityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,6,0,0">
<ComboBoxItem Content="최고 화질 (MP4)" Tag="best"/>
<ComboBoxItem Content="1080p" Tag="1080"/>
<ComboBoxItem Content="720p" Tag="720"/>
<ComboBoxItem Content="480p" Tag="480"/>
<ComboBoxItem Content="360p" Tag="360"/>
<ComboBoxItem Content="오디오만 (MP3)" Tag="audio"/>
</ComboBox>
</StackPanel>
</ScrollViewer>
</Grid>
<StackPanel Margin="0,0,0,18">
<TextBlock Text="YouTube 클라이언트 (고급)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="YoutubeClientsBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<TextBlock Text="비워두면 yt-dlp 기본값 사용(권장). 봇 차단/PO 토큰 오류 시 예: tv,android_vr,web" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,4,0,0" TextWrapping="Wrap"/>
<!-- UC-S03 다시 시작 필요 배너 -->
<Border x:Name="RestartBanner" Grid.Row="1" VerticalAlignment="Bottom" Margin="18,0,18,8"
Background="{DynamicResource AccentFadeBrush}" BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1"
CornerRadius="8" Padding="12,7" Visibility="Collapsed">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="지금 다시 시작" Click="RestartNow_Click"/>
<TextBlock Text="일부 변경(프록시·개인정보 보호 모드)은 앱을 다시 시작하면 적용됩니다."
FontSize="11.5" Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<!-- 하단 액션 바 -->
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="22,12">
<DockPanel>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal">
<!-- UC-S04 기본값 복원 · UC-S05/S06 설정 내보내기/가져오기 -->
<Button x:Name="ResetDefaultsButton" Style="{StaticResource GhostButton}" Content="기본값으로 되돌리기" Click="ResetDefaults_Click" Margin="0,0,8,0"
AutomationProperties.Name="기본값 복원"/>
<Button x:Name="ExportSettingsButton" Style="{StaticResource GhostButton}" Content="내보내기" Click="ExportSettings_Click" Margin="0,0,8,0"
AutomationProperties.Name="설정 내보내기"/>
<Button x:Name="ImportSettingsButton" Style="{StaticResource GhostButton}" Content="가져오기" Click="ImportSettings_Click" Margin="0,0,8,0"
AutomationProperties.Name="설정 가져오기"/>
</StackPanel>
<TextBlock Text="브라우저 데이터" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" Margin="0,0,0,2"/>
<TextBlock x:Name="ProfileModeText" Text="" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,18">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="삭제" Padding="12,4" Margin="8,4,4,4"
ToolTip="쿠키·localStorage·캐시 삭제 (현재 프로필)" Click="ClearData_Click"/>
<TextBox x:Name="ProfilePathBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent" IsReadOnly="True"/>
</DockPanel>
</Border>
<TextBlock Text="반응할 형식" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<TextBlock Text="체크된 형식만 자동 감지/다운로드합니다" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,2,0,10"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="FmtHls" Style="{StaticResource DarkCheckBox}" Content="HLS (m3u8)" Margin="4,5"/>
<CheckBox Name="FmtDash" Style="{StaticResource DarkCheckBox}" Content="DASH (.mpd)" Margin="4,5"/>
<CheckBox Name="FmtMp4" Style="{StaticResource DarkCheckBox}" Content="MP4" Margin="4,5"/>
<CheckBox Name="FmtWebM" Style="{StaticResource DarkCheckBox}" Content="WebM" Margin="4,5"/>
<CheckBox Name="FmtMkv" Style="{StaticResource DarkCheckBox}" Content="MKV" Margin="4,5"/>
<CheckBox Name="FmtM4v" Style="{StaticResource DarkCheckBox}" Content="M4V" Margin="4,5"/>
<CheckBox Name="FmtMov" Style="{StaticResource DarkCheckBox}" Content="MOV" Margin="4,5"/>
<CheckBox Name="FmtAvi" Style="{StaticResource DarkCheckBox}" Content="AVI" Margin="4,5"/>
<CheckBox Name="FmtAudio" Style="{StaticResource DarkCheckBox}" Content="오디오 (m4a/mp3/ogg...)" Margin="4,5"/>
</WrapPanel>
</StackPanel>
</ScrollViewer>
<Border Grid.Row="2" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="22,12">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource GhostButton}" Content="취소" Width="90" Margin="0,0,8,0" IsCancel="True"/>
<Button Style="{StaticResource PrimaryButton}" Content="확인" Width="90" Click="Ok_Click" IsDefault="True"/>
</StackPanel>
<!-- UC-S19 검증 힌트 -->
<TextBlock x:Name="ValidationHint" Text="" Foreground="{DynamicResource ErrorBrush}" FontSize="11.5"
VerticalAlignment="Center" Margin="12,0,12,0" TextTrimming="CharacterEllipsis"
AutomationProperties.LiveSetting="Assertive"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource GhostButton}" Content="취소" Width="90" Margin="0,0,8,0" IsCancel="True" Click="Cancel_Click"/>
<Button Style="{StaticResource PrimaryButton}" Content="확인" Width="90" Click="Ok_Click" IsDefault="True"
AutomationProperties.Name="설정 저장"/>
</StackPanel>
</DockPanel>
</Border>
</Grid>
</Window>

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
@ -13,25 +14,106 @@ namespace VideoDownloader.App;
public partial class SettingsWindow : Window
{
public AppConfig Config { get; }
private readonly string _origTheme;
private readonly AppConfig _original;
private readonly Func<Task>? _clearBrowsingData;
public bool ThemeChanged { get; private set; }
private readonly List<StackPanel> _sections = new();
private bool _needsRestart;
public SettingsWindow(AppConfig config, Func<Task>? clearBrowsingData = null)
{
InitializeComponent();
Config = config;
_origTheme = config.Theme;
_original = CloneConfig(config);
_clearBrowsingData = clearBrowsingData;
LoadValues();
// UC-S02 카테고리 내비게이션 — 섹션 제목과 1:1
var names = new[] { "외관", "글꼴", "일반", "다운로드", "알림", "접근성", "브라우저", "고급", "단축키", "실험적", "정보" };
SettingsNavList.ItemsSource = names;
_sections.Add(SectionAppearance);
_sections.Add(SectionFont);
_sections.Add(SectionGeneral);
_sections.Add(SectionDownload);
_sections.Add(SectionNotify);
_sections.Add(SectionAccessibility);
_sections.Add(SectionBrowser);
if (AdvancedSection.Content is StackPanel advanced) _sections.Add(advanced);
_sections.Add(SectionShortcuts);
_sections.Add(SectionExperimental);
_sections.Add(SectionAbout);
// UC-S20 버전 정보
VersionText.Text = $"Video Downloader {CurrentVersion()} · {MainWindow.ProfileFolder}";
// UC-S08 Ctrl+S 저장
PreviewKeyDown += (_, e) =>
{
if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control) { Save(); DialogResult = true; e.Handled = true; }
};
}
private void LoadValues()
{
var config = Config;
FolderBox.Text = config.OutputFolder;
HomeBox.Text = config.HomeUrl;
ConcurrencyBox.Text = config.Concurrency.ToString();
MinSizeBox.Text = config.MinSizeMB.ToString();
MaxConcurrentBox.Text = config.MaxConcurrentDownloads.ToString();
ThemeLightBox.IsChecked = config.Theme.Equals("light", StringComparison.OrdinalIgnoreCase);
RetriesBox.Text = config.Retries.ToString();
FfmpegBox.Text = config.FfmpegPath ?? "";
// §A 테마 — 즉시 적용(UC-T03): 재시작 없음
ThemeModeBox.SelectedValue = config.Theme;
if (ThemeModeBox.SelectedIndex < 0) ThemeModeBox.SelectedValue = "dark";
var accent = config.AccentColor;
AccentBlue.IsChecked = accent == "#60A5FA" || accent == "#2563EB";
AccentGreen.IsChecked = accent == "#34D399" || accent == "#15803D";
AccentPink.IsChecked = accent == "#F472B6" || accent == "#DB2777";
AccentDefault.IsChecked = !AccentBlue.IsChecked.Value && !AccentGreen.IsChecked.Value && !AccentPink.IsChecked.Value;
AccentColorBox.Text = accent ?? "";
CustomThemeBox.Text = config.CustomThemePath ?? "";
UiDensityBox.SelectedValue = config.UiDensity;
if (UiDensityBox.SelectedIndex < 0) UiDensityBox.SelectedValue = "normal";
UiScaleSlider.Value = Math.Clamp(config.UiScale, 1.0, 2.0);
UiScaleLabel.Text = $"{Math.Round(UiScaleSlider.Value * 100)}%";
UiFontScaleSlider.Value = Math.Clamp(config.UiFontScale <= 0 ? 1.0 : config.UiFontScale, 1.0, 2.0);
UiFontFamilyBox.Text = config.UiFontFamily ?? "";
UiFontKoreanFallbackBox.Text = config.UiFontKoreanFallback ?? "Malgun Gothic";
FontSizeBox.SelectedValue = config.UiFontSize.ToString("0");
if (FontSizeBox.SelectedIndex < 0) FontSizeBox.SelectedValue = "13";
LanguageBox.SelectedValue = config.Language;
if (LanguageBox.SelectedIndex < 0) LanguageBox.SelectedValue = "ko";
AutoStartBox.IsChecked = config.AutoStart;
MinimizeToTrayBox.IsChecked = config.MinimizeToTray;
MaxSpeedBox.Text = config.MaxDownloadSpeedKbps.ToString();
AutoClearCompletedBox.IsChecked = config.AutoClearCompleted;
OpenFolderOnCompleteBox.IsChecked = config.OpenFolderOnComplete;
SubtitleLangBox.SelectedValue = config.SubtitleLanguage;
if (SubtitleLangBox.SelectedIndex < 0) SubtitleLangBox.SelectedItem = SubtitleLangBox.Items.OfType<ComboBoxItem>().First();
ScheduleBox.Text = config.DownloadScheduleWindow ?? "";
NotifySoundBox.IsChecked = config.NotifySound;
DoNotDisturbBox.IsChecked = config.DoNotDisturb;
PauseOnPowerSaverBox.IsChecked = config.PauseOnPowerSaver;
ToastDurationBox.Text = (config.ToastDurationMs / 1000.0).ToString("0");
ReduceMotionBox.IsChecked = config.ReduceMotion;
ShowCaptionsBox.IsChecked = config.ShowCaptions;
FocusSelectsAddressBox.IsChecked = config.FocusSelectsAddress;
PasteAndGoBox.IsChecked = config.PasteAndGo;
TabWheelSwitchBox.IsChecked = config.TabWheelSwitch;
TabStripPositionBox.SelectedValue = config.TabStripPosition;
if (TabStripPositionBox.SelectedIndex < 0) TabStripPositionBox.SelectedValue = "top";
ConfirmCloseMultipleTabsBox.IsChecked = config.ConfirmCloseMultipleTabs;
BookmarkBarVisibleBox.IsChecked = config.BookmarkBarVisible;
FmtHls.IsChecked = config.EnabledFormats.Contains("hls");
FmtDash.IsChecked = config.EnabledFormats.Contains("dash");
FmtMp4.IsChecked = config.EnabledFormats.Contains("mp4");
@ -50,6 +132,11 @@ public partial class SettingsWindow : Window
DefaultQualityBox.SelectedValue = config.DefaultSocialQuality;
if (DefaultQualityBox.SelectedIndex < 0) DefaultQualityBox.SelectedIndex = 0;
YoutubeClientsBox.Text = config.YoutubePlayerClients ?? "";
UserAgentBox.Text = config.UserAgent;
ProxyBox.Text = config.ProxyServer;
RestoreTabsSuspendedBox.IsChecked = config.RestoreTabsSuspended;
TabTitleMultilineBox.IsChecked = config.TabTitleMultiline;
ProfilePathBox.Text = MainWindow.ProfileFolder;
ProfileModeText.Text = config.PrivateMode
@ -59,6 +146,150 @@ public partial class SettingsWindow : Window
SearchEngineBox.ItemsSource = SearchEngines.Presets.Select(e => e.Name).ToList();
SearchEngineBox.SelectedItem = SearchEngines.FindByName(config.SearchEngine)?.Name ?? "Google";
if (SearchEngineBox.SelectedIndex < 0) SearchEngineBox.SelectedIndex = 0;
UpdateRepoBox.Text = config.UpdateRepo;
// UC-F14 폰트 미리보기 — 현재 설정 반영
RefreshFontPreview();
}
// ---- UC-S01 검색: 섹션 제목·내용에 검색어가 없으면 숨김 ----
private void SettingsSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = SettingsSearchBox.Text?.Trim() ?? "";
foreach (var s in _sections)
{
var text = SectionText(s);
s.Visibility = q.Length == 0 || text.Contains(q, StringComparison.OrdinalIgnoreCase)
? Visibility.Visible : Visibility.Collapsed;
}
}
private static string SectionText(FrameworkElement section)
{
var sb = new System.Text.StringBuilder();
sb.Append(section.Tag ?? "");
CollectText(section, sb);
return sb.ToString();
}
private static void CollectText(DependencyObject d, System.Text.StringBuilder sb)
{
foreach (var child in System.Windows.LogicalTreeHelper.GetChildren(d))
{
switch (child)
{
case TextBlock tb: sb.AppendLine(tb.Text); break;
case TextBox tx: sb.AppendLine(tx.Text); break;
case DependencyObject dep: CollectText(dep, sb); break;
}
}
}
// ---- UC-S02 내비게이션: 섹션 스크롤 ----
private void SettingsNav_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (SettingsNavList.SelectedIndex < 0 || SettingsNavList.SelectedIndex >= _sections.Count) return;
_sections[SettingsNavList.SelectedIndex].BringIntoView();
}
// ---- §A 테마 라이브 적용(UC-T03/T04) ----
private void ThemeModeBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (ThemeModeBox?.SelectedValue == null) return;
var theme = ThemeModeBox.SelectedValue as string ?? "dark";
Config.Theme = theme;
App.ApplyTheme(theme, Config.CustomThemePath);
App.ApplyAccent(Config.AccentColor);
RefreshTitleBarChrome();
(Owner as MainWindow)?.UpdateTitleBarTheme();
}
private void Accent_Checked(object sender, RoutedEventArgs e)
{
if (AccentColorBox == null) return;
if (ReferenceEquals(sender, AccentDefault)) AccentColorBox.Text = "";
else if (ReferenceEquals(sender, AccentBlue)) AccentColorBox.Text = "#2563EB";
else if (ReferenceEquals(sender, AccentGreen)) AccentColorBox.Text = "#15803D";
else if (ReferenceEquals(sender, AccentPink)) AccentColorBox.Text = "#DB2777";
}
private void BrowseThemeFile(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "테마 파일|*.xaml" };
if (dlg.ShowDialog() == true)
{
CustomThemeBox.Text = dlg.FileName;
Config.CustomThemePath = dlg.FileName;
App.ApplyTheme(Config.Theme, dlg.FileName); // 즉시 미리보기
RefreshTitleBarChrome();
}
}
// ---- §B/§C 라이브 미리보기 ----
private void RefreshFontPreview()
{
var size = double.TryParse(FontSizeBox?.SelectedValue as string, out var s) ? s : 13;
FontPreviewText.FontSize = size * Math.Clamp(UiFontScaleSlider.Value, 1.0, 2.0);
}
private void UiScaleSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (UiScaleLabel != null) UiScaleLabel.Text = $"{Math.Round(e.NewValue * 100)}%";
(Owner as MainWindow)?.ApplyUiScale(e.NewValue); // UC-L25 라이브 미리보기
}
private void UiFontScaleSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) => RefreshFontPreview();
// ---- DEPLOYMENT_PLAN §6 v1 업데이트 확인 ----
internal async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
{
var repo = UpdateRepoBox.Text?.Trim() ?? "";
if (repo.Length == 0 || !repo.Contains('/'))
{
UpdateResultText.Text = "저장소를 owner/repo 형식으로 입력하세요 (예: user/videodownloader)";
return;
}
var btn = sender as Button;
if (btn != null) btn.IsEnabled = false;
try
{
var current = CurrentVersion();
var rid = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "win-arm64" : "win-x64";
var result = await new VideoDownloader.Core.Update.UpdateChecker().CheckAsync(repo, current, rid);
if (result.IsNewer)
{
UpdateResultText.Text = $"새 버전 {result.LatestVersion} (현재 {current})";
var open = MessageBox.Show(
$"새 버전 {result.LatestVersion} 이(가) 있습니다 (현재 {current}).\n릴리스 페이지를 열까요?",
"업데이트", MessageBoxButton.YesNo, MessageBoxImage.Information);
if (open == MessageBoxResult.Yes)
{
var url = result.Asset?.DownloadUrl ?? result.ReleaseUrl;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
}
}
else
{
UpdateResultText.Text = $"최신 버전입니다 (현재 {current}, 최신 {result.LatestVersion})";
}
}
catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or TaskCanceledException)
{
UpdateResultText.Text = $"확인 실패: {ex.Message}";
}
finally { if (btn != null) btn.IsEnabled = true; }
}
private static string CurrentVersion()
{
var asm = System.Reflection.Assembly.GetExecutingAssembly();
var info = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>().FirstOrDefault()?.InformationalVersion;
// 소스 링크 해시(+abc...) 는 버전 비교에서 제외
if (!string.IsNullOrEmpty(info)) return info.Split('+')[0];
return asm.GetName().Version?.ToString() ?? "0.0.0";
}
private void SearchEngineBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
@ -117,17 +348,149 @@ public partial class SettingsWindow : Window
finally { if (btn != null) btn.IsEnabled = true; }
}
private void Ok_Click(object sender, RoutedEventArgs e)
// ---- UC-S04/S05/S06 기본값·내보내기·가져오기 ----
private void ResetDefaults_Click(object sender, RoutedEventArgs e)
{
if (MessageBox.Show("모든 설정을 기본값으로 되돌릴까요? 저장 폴더·토큰도 초기화됩니다.",
"기본값 복원", MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
Config.ResetToDefaults();
LoadValues();
ValidationHint.Text = "기본값으로 되돌렸습니다 — 확인을 누르면 저장됩니다";
}
private void ExportSettings_Click(object sender, RoutedEventArgs e)
{
Save(); // 화면 값 → Config 반영 후 내보냄
var dlg = new SaveFileDialog { Filter = "설정 JSON|*.json", FileName = "videodownloader-settings.json" };
if (dlg.ShowDialog() != true) return;
File.WriteAllText(dlg.FileName, System.Text.Json.JsonSerializer.Serialize(Config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
private void ImportSettings_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "설정 JSON|*.json" };
if (dlg.ShowDialog() != true) return;
try
{
var imported = System.Text.Json.JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(dlg.FileName));
if (imported == null) throw new InvalidOperationException("빈 설정 파일");
ImportInto(imported);
LoadValues();
ValidationHint.Text = "설정을 가져왔습니다 — 확인을 누르면 저장됩니다";
}
catch (Exception ex)
{
ValidationHint.Text = $"가져오기 실패: {ex.Message}";
}
}
private void ImportInto(AppConfig src)
{
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
{
if (prop.Name == nameof(AppConfig.RemoteAccessToken)) continue; // 토큰은 현재 기기 값 유지
try { prop.SetValue(Config, prop.GetValue(src)); } catch { }
}
}
private void RestartNow_Click(object sender, RoutedEventArgs e)
{
Save();
MainWindow.RestartApp();
}
/// <summary>UC-S09 취소 시 변경 폐기 확인.</summary>
public bool ConfirmDiscardChanges()
{
if (!IsDirty()) return true;
return MessageBox.Show(this, "저장하지 않은 변경 사항이 있습니다.\n폐기하고 닫을까요?",
"변경 사항", MessageBoxButton.OKCancel, MessageBoxImage.Warning) == MessageBoxResult.OK;
}
private bool IsDirty() => !System.Text.Json.JsonSerializer.Serialize(Config).Equals(
System.Text.Json.JsonSerializer.Serialize(_original));
private static AppConfig CloneConfig(AppConfig c)
{
var clone = new AppConfig();
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
try { prop.SetValue(clone, prop.GetValue(c)); } catch { }
return clone;
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
if (ConfirmDiscardChanges()) Close();
}
/// <summary>화면 값 → Config 반영(검증 포함). 오류는 ValidationHint 에 표시하고 false 반환.</summary>
private bool Save()
{
ValidationHint.Text = "";
if (!int.TryParse(ConcurrencyBox.Text, out var c) || c < 1) return Invalid("세그먼트 동시 수는 1 이상의 숫자");
if (!int.TryParse(MaxConcurrentBox.Text, out var mc) || mc < 1) return Invalid("최대 동시 항목은 1 이상의 숫자");
if (!int.TryParse(RetriesBox.Text, out var retries) || retries < 1) return Invalid("재시도 횟수는 1 이상의 숫자");
if (!long.TryParse(MinSizeBox.Text, out var m) || m < 0) return Invalid("최소 크기는 0 이상의 숫자");
if (!int.TryParse(MaxSpeedBox.Text, out var speed) || speed < 0) return Invalid("속도 상한은 0 이상의 숫자");
if (!double.TryParse(ToastDurationBox.Text, out var toastSec) || toastSec < 0.5) return Invalid("토스트 시간은 0.5초 이상");
if (!string.IsNullOrWhiteSpace(ScheduleBox.Text) && !Services.DownloadScheduler.TryParse(ScheduleBox.Text.Trim(), out _, out _))
return Invalid("예약 시간대는 HH:mm-HH:mm 형식(예: 02:00-08:00)");
var accent = AccentColorBox.Text?.Trim();
if (!string.IsNullOrEmpty(accent) &&
!(accent.Length == 7 && accent.StartsWith('#') && accent[1..].All(ch => Uri.IsHexDigit(ch))))
return Invalid("강조색은 #RRGGBB 형식");
Config.OutputFolder = FolderBox.Text?.Trim() ?? "";
Config.HomeUrl = string.IsNullOrWhiteSpace(HomeBox.Text) ? "https://www.google.com" : HomeBox.Text.Trim();
if (int.TryParse(ConcurrencyBox.Text, out var c)) Config.Concurrency = Math.Max(1, c);
if (long.TryParse(MinSizeBox.Text, out var m) && m >= 0) Config.MinSizeMB = m;
Config.Concurrency = Math.Max(1, c);
Config.MaxConcurrentDownloads = Math.Max(1, mc);
Config.Retries = Math.Max(1, retries);
Config.MinSizeMB = Math.Max(0, m);
Config.MaxDownloadSpeedKbps = speed;
Config.FfmpegPath = string.IsNullOrWhiteSpace(FfmpegBox.Text) ? null : FfmpegBox.Text.Trim();
Config.Theme = ThemeLightBox.IsChecked == true ? "light" : "dark";
if (int.TryParse(MaxConcurrentBox.Text, out var mc)) Config.MaxConcurrentDownloads = Math.Max(1, mc);
ThemeChanged = !Config.Theme.Equals(_origTheme, StringComparison.OrdinalIgnoreCase);
Config.Theme = ThemeModeBox.SelectedValue as string ?? "dark";
Config.AccentColor = string.IsNullOrEmpty(accent) ? null : accent;
Config.CustomThemePath = string.IsNullOrWhiteSpace(CustomThemeBox.Text) ? null : CustomThemeBox.Text.Trim();
Config.UiDensity = UiDensityBox.SelectedValue as string ?? "normal";
Config.UiScale = Math.Clamp(UiScaleSlider.Value, 1.0, 2.0);
Config.UiFontScale = Math.Clamp(UiFontScaleSlider.Value, 1.0, 2.0);
Config.UiFontFamily = UiFontFamilyBox.Text?.Trim() ?? "";
Config.UiFontKoreanFallback = string.IsNullOrWhiteSpace(UiFontKoreanFallbackBox.Text) ? "Malgun Gothic" : UiFontKoreanFallbackBox.Text.Trim();
Config.UiFontSize = double.TryParse(FontSizeBox.SelectedValue as string, out var fs) ? fs : 13;
Config.Language = LanguageBox.SelectedValue as string ?? "ko";
Config.AutoStart = AutoStartBox.IsChecked == true;
Config.MinimizeToTray = MinimizeToTrayBox.IsChecked == true;
Config.SearchEngine = SearchEngineBox.SelectedItem as string ?? "Google";
Config.UpdateRepo = UpdateRepoBox.Text?.Trim() ?? "";
Config.UserAgent = string.IsNullOrWhiteSpace(UserAgentBox.Text)
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
: UserAgentBox.Text.Trim();
var proxyChanged = Config.ProxyServer != (ProxyBox.Text?.Trim() ?? "");
Config.ProxyServer = ProxyBox.Text?.Trim() ?? "";
Config.AutoClearCompleted = AutoClearCompletedBox.IsChecked == true;
Config.OpenFolderOnComplete = OpenFolderOnCompleteBox.IsChecked == true;
Config.SubtitleLanguage = (SubtitleLangBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
Config.DownloadScheduleWindow = string.IsNullOrWhiteSpace(ScheduleBox.Text) ? null : ScheduleBox.Text.Trim();
Config.NotifySound = NotifySoundBox.IsChecked == true;
Config.DoNotDisturb = DoNotDisturbBox.IsChecked == true;
Config.PauseOnPowerSaver = PauseOnPowerSaverBox.IsChecked == true;
Config.ToastDurationMs = (int)(toastSec * 1000);
Config.ReduceMotion = ReduceMotionBox.IsChecked == true;
Config.ShowCaptions = ShowCaptionsBox.IsChecked == true;
Config.FocusSelectsAddress = FocusSelectsAddressBox.IsChecked == true;
Config.PasteAndGo = PasteAndGoBox.IsChecked == true;
Config.TabWheelSwitch = TabWheelSwitchBox.IsChecked == true;
Config.TabStripPosition = TabStripPositionBox.SelectedValue as string ?? "top";
Config.ConfirmCloseMultipleTabs = ConfirmCloseMultipleTabsBox.IsChecked == true;
Config.BookmarkBarVisible = BookmarkBarVisibleBox.IsChecked == true;
Config.RestoreTabsSuspended = RestoreTabsSuspendedBox.IsChecked == true;
Config.TabTitleMultiline = TabTitleMultilineBox.IsChecked == true;
var fmts = new List<string>();
if (FmtHls.IsChecked == true) fmts.Add("hls");
@ -148,18 +511,63 @@ public partial class SettingsWindow : Window
Config.AutoInstallDeno = AutoInstallDenoBox.IsChecked == true;
Config.DefaultSocialQuality = (DefaultQualityBox.SelectedValue as string) ?? "best";
Config.YoutubePlayerClients = string.IsNullOrWhiteSpace(YoutubeClientsBox.Text) ? null : YoutubeClientsBox.Text.Trim();
Config.SearchEngine = SearchEngineBox.SelectedItem as string ?? "Google";
// 즉시 반영(재시작 없음 — §A/§B/§G)
App.ApplyFontSettings();
App.ApplyReduceMotion(Config.ReduceMotion);
App.ApplyAccent(Config.AccentColor);
// UC-S15 자동 실행 등록(HKCU Run)
ApplyAutoStart(Config.AutoStart);
// UC-S03 재시작 배너 — 프록시가 바뀌었으면 알림
if (proxyChanged) _needsRestart = true;
RestartBanner.Visibility = _needsRestart ? Visibility.Visible : Visibility.Collapsed;
return true;
}
private bool Invalid(string message)
{
ValidationHint.Text = message;
return false;
}
/// <summary>Windows 시작 자동 실행(HKCU\...\Run) 등록/해제.</summary>
private static void ApplyAutoStart(bool enable)
{
try
{
using var key = Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run");
var name = "VideoDownloader";
if (enable)
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe)) key.SetValue(name, $"\"{exe}\"");
}
else key.DeleteValue(name, throwOnMissingValue: false);
}
catch { }
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
if (!Save()) return;
DialogResult = true;
}
// 보더리스: Win11 어두운 테두리 + 둥근 모서리
// 보더리스: Win11 어두운 테두리 + 둥근 모서리. 테마 라이브 전환 시 재적용(RefreshTitleBarChrome).
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
RefreshTitleBarChrome();
}
internal void RefreshTitleBarChrome()
{
try
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
int dark = App.IsLightTheme ? 0 : 1; // DWMWA_USE_IMMERSIVE_DARK_MODE (라이트 테마면 밝게)
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
int round = 2; // DWMWCP_ROUND

View file

@ -1,7 +1,8 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 다크 팔레트 (단일 보라 액센트 - 의도적 통일) ===== -->
<!-- ===== 다크 팔레트 (단일 보라 액센트 - 의도적 통일) =====
UC-T14/T15/T18/A20: WCAG 대비 결함 수정 — TextMuted/BorderBrush/AccentBrush 밝게 조정. -->
<SolidColorBrush x:Key="WindowBg" Color="#0B0B10"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#11111B" Offset="0"/>
@ -12,24 +13,38 @@
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
<SolidColorBrush x:Key="BorderBrush" Color="#343442"/>
<SolidColorBrush x:Key="BorderBrush" Color="#6C6C8A"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
<SolidColorBrush x:Key="TextSecondary" Color="#A9B0C5"/>
<SolidColorBrush x:Key="TextMuted" Color="#7C8296"/>
<SolidColorBrush x:Key="TextMuted" Color="#8B91A6"/>
<SolidColorBrush x:Key="TextDisabled" Color="#5A5F72"/>
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>
<SolidColorBrush x:Key="PendingBrush" Color="#FBBF24"/>
<SolidColorBrush x:Key="WarningBrush" Color="#FB923C"/>
<SolidColorBrush x:Key="InfoBrush" Color="#60A5FA"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#4A3F9E"/>
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#B4A7FF"/>
<SolidColorBrush x:Key="LinkBrush" Color="#93B4FF"/>
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#C4A9E8"/>
<SolidColorBrush x:Key="AccentTextBrush" Color="#A99DFF"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#A855F7" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="AccentBrush" Color="#7C6CFF"/>
<SolidColorBrush x:Key="AccentBrush" Color="#8B7CFF"/>
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#556366F1" Offset="0"/>
<GradientStop Color="#006366F1" Offset="1"/>
</LinearGradientBrush>
<!-- UC-T09 강조색 프리셋 스와치 (설정창 색 선택기가 참조) -->
<SolidColorBrush x:Key="Accent.Blue" Color="#60A5FA"/>
<SolidColorBrush x:Key="Accent.Green" Color="#34D399"/>
<SolidColorBrush x:Key="Accent.Pink" Color="#F472B6"/>
<SolidColorBrush x:Key="Accent.Orange" Color="#FB923C"/>
</ResourceDictionary>

View file

@ -0,0 +1,49 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 고대비 팔레트 (UC-T05/A04) — Windows 고대비 테마 관례: 검정 배경 + 원색.
Dark/Light 와 동일 키를 전부 정의해 Shared.xaml 스타일이 그대로 동작한다. ===== -->
<SolidColorBrush x:Key="WindowBg" Color="#000000"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#000000" Offset="0"/>
<GradientStop Color="#000000" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PanelBg" Color="#000000"/>
<SolidColorBrush x:Key="GlassBg" Color="#1A1A1A"/>
<SolidColorBrush x:Key="CardBg" Color="#000000"/>
<SolidColorBrush x:Key="CardBgHover" Color="#2D2D2D"/>
<SolidColorBrush x:Key="BorderBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#767676"/>
<SolidColorBrush x:Key="TextPrimary" Color="#FFFFFF"/>
<SolidColorBrush x:Key="TextSecondary" Color="#E8E8E8"/>
<SolidColorBrush x:Key="TextMuted" Color="#D0D0D0"/>
<SolidColorBrush x:Key="TextDisabled" Color="#8A8A8A"/>
<SolidColorBrush x:Key="TrackBrush" Color="#5A5A5A"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#00FF66"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#FF5A5A"/>
<SolidColorBrush x:Key="PendingBrush" Color="#FFFF00"/>
<SolidColorBrush x:Key="WarningBrush" Color="#FFA500"/>
<SolidColorBrush x:Key="InfoBrush" Color="#00C8FF"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#FFFF00"/>
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#FFFF00"/>
<SolidColorBrush x:Key="LinkBrush" Color="#00C8FF"/>
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#C77BFF"/>
<SolidColorBrush x:Key="AccentTextBrush" Color="#FFFF00"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FFFF00" Offset="0"/>
<GradientStop Color="#FFFF00" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="AccentBrush" Color="#FFFF00"/>
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#66FFFF00" Offset="0"/>
<GradientStop Color="#00FFFF00" Offset="1"/>
</LinearGradientBrush>
<!-- UC-T09 강조색 프리셋 (고대비에서도 동일 키 제공) -->
<SolidColorBrush x:Key="Accent.Blue" Color="#00C8FF"/>
<SolidColorBrush x:Key="Accent.Green" Color="#00FF66"/>
<SolidColorBrush x:Key="Accent.Pink" Color="#FF6EC7"/>
<SolidColorBrush x:Key="Accent.Orange" Color="#FFA500"/>
</ResourceDictionary>

View file

@ -1,7 +1,8 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 라이트 팔레트 (동일 키 - Shared.xaml 의 스타일이 그대로 동작) ===== -->
<!-- ===== 라이트 팔레트 (동일 키 - Shared.xaml 의 스타일이 그대로 동작) =====
UC-T16/T17/A19: TextMuted 어둡게(#63687A), TrackBrush 명확히(#808798) 조정. -->
<SolidColorBrush x:Key="WindowBg" Color="#F6F7F9"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FFFFFF" Offset="0"/>
@ -12,15 +13,23 @@
<SolidColorBrush x:Key="GlassBg" Color="#E9EBF0"/>
<SolidColorBrush x:Key="CardBg" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBgHover" Color="#EEF0F4"/>
<SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/>
<SolidColorBrush x:Key="BorderBrush" Color="#AEB4C4"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
<SolidColorBrush x:Key="TextSecondary" Color="#4A4E5E"/>
<SolidColorBrush x:Key="TextMuted" Color="#767B8C"/>
<SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/>
<SolidColorBrush x:Key="TextMuted" Color="#63687A"/>
<SolidColorBrush x:Key="TextDisabled" Color="#9DA2B1"/>
<SolidColorBrush x:Key="TrackBrush" Color="#808798"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>
<SolidColorBrush x:Key="PendingBrush" Color="#C77708"/>
<SolidColorBrush x:Key="WarningBrush" Color="#B45309"/>
<SolidColorBrush x:Key="InfoBrush" Color="#2563EB"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#C7CFFB"/>
<SolidColorBrush x:Key="FocusAdornerBrush" Color="#5B5FE3"/>
<SolidColorBrush x:Key="LinkBrush" Color="#2C5BD1"/>
<SolidColorBrush x:Key="VisitedLinkBrush" Color="#7C3AAB"/>
<SolidColorBrush x:Key="AccentTextBrush" Color="#5B5FE3"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#6366F1" Offset="0"/>
@ -32,4 +41,10 @@
<GradientStop Color="#306366F1" Offset="0"/>
<GradientStop Color="#006366F1" Offset="1"/>
</LinearGradientBrush>
<!-- UC-T09 강조색 프리셋 스와치 (다크와 동일 키 — 배경에 따라 살짝 어둡게) -->
<SolidColorBrush x:Key="Accent.Blue" Color="#2563EB"/>
<SolidColorBrush x:Key="Accent.Green" Color="#15803D"/>
<SolidColorBrush x:Key="Accent.Pink" Color="#DB2777"/>
<SolidColorBrush x:Key="Accent.Orange" Color="#C2410C"/>
</ResourceDictionary>

View file

@ -1,37 +1,144 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<!-- 폰트 (테마 무관) — 한국어 폴백 포함(맑은 고딕) -->
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
<!-- 폰트 (테마 무관) — 한국어 가독성 프리셋 Pretendard(UC-F12) 우선 + 폴백 체인.
브러시/폰트/토큰 참조는 DynamicResource 로 연결해 테마·폰트·설정 변경이 즉시 반영된다(UC-T04). -->
<FontFamily x:Key="UiFont">Pretendard, Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
<FontFamily x:Key="MonoFont">Cascadia Mono, Consolas, Courier New</FontFamily>
<!-- ===== 디자인 토큰 (§B 타이포그래피 · §C 레이아웃 · UC-L22 지속시간) ===== -->
<sys:Double x:Key="FontSize.Base">13</sys:Double>
<sys:Double x:Key="FontSize.Small">11.5</sys:Double>
<sys:Double x:Key="FontSize.Large">16</sys:Double>
<sys:Double x:Key="FontSize.XLarge">22</sys:Double>
<sys:Double x:Key="IconSize.Small">14</sys:Double>
<sys:Double x:Key="Type.LineHeight">1.5</sys:Double>
<Thickness x:Key="Spacing.S">4,4,4,4</Thickness>
<Thickness x:Key="Spacing.M">8,8,8,8</Thickness>
<Thickness x:Key="Spacing.L">16,16,16,16</Thickness>
<CornerRadius x:Key="CornerRadius.Card">12</CornerRadius>
<Duration x:Key="Duration.Fast">0:0:0.15</Duration>
<Duration x:Key="Duration.Normal">0:0:0.3</Duration>
<!-- UC-A16/L21: ReduceMotion 켜면 App 이 Duration 토큰들을 0으로 교체해 애니메이션을 즉시 완결시킨다. -->
<sys:Boolean x:Key="ReduceMotion">False</sys:Boolean>
<!-- ===== 그림자 토큰 (UC-T23/T24) ===== -->
<DropShadowEffect x:Key="TooltipShadow" Color="#000000" Opacity="0.45" BlurRadius="14" ShadowDepth="3" Direction="270"/>
<DropShadowEffect x:Key="PopupShadow" Color="#000000" Opacity="0.4" BlurRadius="20" ShadowDepth="5" Direction="270"/>
<!-- ===== 스크롤바 썸 전용 색 (UC-T22) ===== -->
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#555C70"/>
<!-- ===== 탭 그룹 색 팔레트 (UC-B17) — MainWindow 탭 컨텍스트 메뉴가 사용 ===== -->
<x:Array x:Key="GroupPalette" Type="SolidColorBrush">
<SolidColorBrush Color="#E74C3C"/>
<SolidColorBrush Color="#3498DB"/>
<SolidColorBrush Color="#2ECC71"/>
<SolidColorBrush Color="#F39C12"/>
<SolidColorBrush Color="#9B59B6"/>
<SolidColorBrush Color="#E91E8C"/>
</x:Array>
<!-- 암시 스타일: 모든 텍스트가 UI 폰트로 통일(Foreground 는 상속 존중) -->
<Style TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
</Style>
<Style TargetType="ToolTip">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="{DynamicResource CardBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="10,6"/>
<!-- UC-T23 툴팁 그림자 -->
<Setter Property="Effect" Value="{StaticResource TooltipShadow}"/>
</Style>
<!-- UC-A05 키보드 포커스 링 — 모든 인터랙티브 스타일이 참조 -->
<Style x:Key="FocusVisual">
<Setter Property="Control.Margin" Value="2"/>
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate>
<Rectangle Margin="2" StrokeThickness="1.5" Stroke="{DynamicResource FocusAdornerBrush}"
StrokeDashArray="4 2" SnapsToDevicePixels="True"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 공용 텍스트 스타일 (§B) ===== -->
<Style x:Key="Type.H1" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.XLarge}"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="LineHeight" Value="{DynamicResource Type.LineHeight}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Style>
<Style x:Key="Type.Body" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.Base}"/>
<Setter Property="LineHeight" Value="{DynamicResource Type.LineHeight}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
<Style x:Key="Type.Caption" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.Small}"/>
<Setter Property="Foreground" Value="{DynamicResource TextMuted}"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
<!-- UC-F11 등폭 숫자 — 진행률·속도 표기에 사용 -->
<Style x:Key="Type.Mono" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="{DynamicResource FontSize.Small}"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
</Style>
<!-- UC-F19 탭 제목 -->
<Style x:Key="TabTitleStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
</Style>
<!-- UC-F20 상태바 텍스트 -->
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="11.5"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
</Style>
<!-- UC-N17 빈 상태 안내 -->
<Style x:Key="EmptyStateStyle" TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="Margin" Value="8"/>
</Style>
<!-- ===== Primary 버튼 ===== -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{StaticResource AccentGradient}" CornerRadius="8" Padding="{TemplateBinding Padding}">
<Border x:Name="bd" Background="{DynamicResource AccentGradient}" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
@ -39,7 +146,10 @@
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter TargetName="bd" Property="Opacity" Value="0.35"/></Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.35"/>
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
@ -48,28 +158,33 @@
<!-- ===== Ghost 버튼 ===== -->
<Style x:Key="GhostButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="12,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="{TemplateBinding Padding}">
<Border x:Name="bd" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter TargetName="bd" Property="Opacity" Value="0.4"/></Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.4"/>
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
@ -78,28 +193,28 @@
<!-- ===== 아이콘 전용 버튼 (Fluent) ===== -->
<Style x:Key="IconButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
</Style>
<!-- ===== Fluent 아이콘 TextBlock ===== -->
<Style x:Key="FluentIcon" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- ===== 윈도우 캡션 버튼 (최소화/최대화/복원) — 고정폭, 서로 밀착(네이티브 Fluent) ===== -->
<Style x:Key="CaptionButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
<Setter Property="Cursor" Value="Arrow"/>
<Setter Property="Template">
<Setter.Value>
@ -109,11 +224,11 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="Background" Value="{StaticResource BorderBrush}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource BorderBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -148,12 +263,14 @@
<!-- ===== 투명 행 버튼 ===== -->
<Style x:Key="FlatButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
@ -162,7 +279,7 @@
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/></Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
@ -179,8 +296,9 @@
</Style>
<Style x:Key="SuggestItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="12,7"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
@ -188,8 +306,8 @@
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/></Trigger>
<Trigger Property="IsSelected" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource GlassBg}"/></Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource CardBgHover}"/></Trigger>
<Trigger Property="IsSelected" Value="True"><Setter TargetName="bd" Property="Background" Value="{DynamicResource GlassBg}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
@ -198,15 +316,16 @@
<!-- ===== TextBox ===== -->
<Style x:Key="DarkTextBox" TargetType="TextBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource GlassBg}"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimary}"/>
<Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="{DynamicResource GlassBg}"/>
<Setter Property="CaretBrush" Value="{DynamicResource TextPrimary}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource SelectionBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
@ -215,31 +334,27 @@
Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/></Trigger>
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== ProgressBar ===== -->
<!-- ===== ProgressBar (UC-T10: 하드코딩 색 제거 — 테마 강조 리소스만 참조) ===== -->
<Style x:Key="AccentProgressBar" TargetType="ProgressBar">
<Setter Property="Height" Value="8"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid>
<!-- PART_Track 이 있어야 ProgressBar 가 PART_Indicator 폭을(Value/Maximum 비율) 계산한다.
없으면 바가 절대 채워지지 않는 치명 결함이었음. -->
<Border x:Name="PART_Track" CornerRadius="4" Background="{StaticResource TrackBrush}"/>
<Border x:Name="PART_Indicator" CornerRadius="4" HorizontalAlignment="Left">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#A855F7" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="PART_Track" CornerRadius="4" Background="{DynamicResource TrackBrush}"/>
<Border x:Name="PART_Indicator" CornerRadius="4" HorizontalAlignment="Left"
Background="{DynamicResource AccentGradient}"/>
</Grid>
</ControlTemplate>
</Setter.Value>
@ -249,19 +364,14 @@
<!-- ===== 부정확(길이 미상) 진행률 바 ===== -->
<Style x:Key="IndeterminateBar" TargetType="ProgressBar">
<Setter Property="Height" Value="8"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid ClipToBounds="True">
<Border CornerRadius="4" Background="{StaticResource TrackBrush}"/>
<Border x:Name="shimmer" Width="48" CornerRadius="4" HorizontalAlignment="Left" RenderTransformOrigin="0,0">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#556366F1" Offset="0"/>
<GradientStop Color="#CCA855F7" Offset="0.5"/>
<GradientStop Color="#556366F1" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Border CornerRadius="4" Background="{DynamicResource TrackBrush}"/>
<Border x:Name="shimmer" Width="48" CornerRadius="4" HorizontalAlignment="Left" RenderTransformOrigin="0,0"
Background="{DynamicResource AccentBrush}" Opacity="0.85">
<Border.RenderTransform><TranslateTransform X="-50"/></Border.RenderTransform>
</Border>
</Grid>
@ -271,7 +381,7 @@
<Storyboard>
<DoubleAnimation Storyboard.TargetName="shimmer"
Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
From="-50" To="470" Duration="0:0:1.3" RepeatBehavior="Forever"/>
From="-50" To="470" Duration="{DynamicResource Duration.Normal}" RepeatBehavior="Forever"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
@ -283,46 +393,82 @@
<!-- ===== CheckBox ===== -->
<Style x:Key="DarkCheckBox" TargetType="CheckBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="box" Width="18" Height="18" CornerRadius="5" Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1.5">
<Border x:Name="box" Width="18" Height="18" CornerRadius="5" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5">
<Path x:Name="check" Width="10" Height="10" Stretch="Uniform" Data="M 0 5 L 4 9 L 10 0" Stroke="White" StrokeThickness="2" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
</Border>
<ContentPresenter VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{StaticResource AccentGradient}"/>
<Setter TargetName="box" Property="Background" Value="{DynamicResource AccentGradient}"/>
<Setter TargetName="box" Property="BorderBrush" Value="Transparent"/>
<Setter TargetName="check" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{StaticResource AccentBrush}"/></Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== ComboBox (어두운 테마, 카드 화질 선택용) ===== -->
<!-- ===== RadioButton(DarkCheckBox 와 같은 모양, 라디오 그룹용) ===== -->
<Style x:Key="DarkRadioButton" TargetType="RadioButton">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="box" Width="18" Height="18" CornerRadius="9" Background="{DynamicResource GlassBg}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5">
<Ellipse x:Name="dot" Width="8" Height="8" Fill="White" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
</Border>
<ContentPresenter VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{DynamicResource AccentGradient}"/>
<Setter TargetName="box" Property="BorderBrush" Value="Transparent"/>
<Setter TargetName="dot" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/></Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter Property="Foreground" Value="{DynamicResource TextDisabled}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== ComboBox ===== -->
<Style x:Key="DarkComboBox" TargetType="ComboBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource GlassBg}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="{DynamicResource GlassBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Padding" Value="10,5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
@ -333,10 +479,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ibd" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ibd" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
<Setter TargetName="ibd" Property="Background" Value="{DynamicResource AccentFadeBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -373,14 +519,16 @@
TextBlock.Foreground="{TemplateBinding Foreground}"
TextBlock.FontSize="{TemplateBinding FontSize}"/>
<TextBlock x:Name="arrow" Grid.Column="1" Text="&#xE70D;" Style="{StaticResource FluentIcon}"
FontSize="9" Foreground="{StaticResource TextMuted}"
FontSize="9" Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center" Margin="10,0,0,0" IsHitTestVisible="False"/>
</Grid>
<!-- UC-A14 팝업 내 방향키/Tab 탐색 -->
<Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
<Border x:Name="DropDown" Background="{StaticResource CardBg}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="4"
MinWidth="{TemplateBinding ActualWidth}" MaxHeight="340">
<Border x:Name="DropDown" Background="{DynamicResource CardBg}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="4"
MinWidth="{TemplateBinding ActualWidth}" MaxHeight="340"
Effect="{StaticResource PopupShadow}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
@ -389,7 +537,7 @@
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="MainBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="MainBorder" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5"/>
@ -400,7 +548,7 @@
</Setter>
</Style>
<!-- ===== 얇은 스크롤바 ===== -->
<!-- ===== 얇은 스크롤바 (UC-T22 썸 색 토큰) ===== -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
@ -415,7 +563,7 @@
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border CornerRadius="4" Background="{StaticResource BorderBrush}"/>
<Border CornerRadius="4" Background="{DynamicResource ScrollBarThumbBrush}"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>

View file

@ -42,6 +42,19 @@ public class DownloadItemViewModel : INotifyPropertyChanged
public CancellationTokenSource? Cts { get; set; }
public bool Started { get; set; }
/// <summary>§E 일시정지 상태(재개 대기) — 임시 파일 보존·"일시정지" 상태 표시 판단에 사용.</summary>
public bool PausedForResume { get; set; }
/// <summary>UC-D30 사이트별 묶음 — 출처 페이지 호스트(없으면 미디어 URL 호스트).</summary>
public string SiteName
{
get
{
var source = PageUrl ?? Url ?? "";
return Uri.TryCreate(source, UriKind.Absolute, out var u) ? u.Host : "직접 링크";
}
}
public string Status { get => _status; set => SetField(ref _status, value); }
public int Progress { get => _progress; set => SetField(ref _progress, value); }
// #9 직링크 길이 미상 시 진행률 바를 부정확(애니메이션) 모드로
@ -50,6 +63,71 @@ public class DownloadItemViewModel : INotifyPropertyChanged
public string? OutputPath { get => _outputPath; set => SetField(ref _outputPath, value); }
public string? Error { get => _error; set => SetField(ref _error, value); }
// ---- §E 다운로드 관리자 확장 (docs/UI_USECASES.md) ----
/// <summary>UC-D05 현재 속도 표시("2.4 MB/s" 등). DownloadManager 진행 콜백에서 갱신.</summary>
public string SpeedText { get => _speedText; set => SetField(ref _speedText, value); }
private string _speedText = "";
/// <summary>UC-D04 남은 시간 추정("3분 남음" 등). 속도·진행률로 계산.</summary>
public string RemainingText { get => _remainingText; set => SetField(ref _remainingText, value); }
private string _remainingText = "";
/// <summary>UC-D19 파일 총 크기 표시("142.5 MB" 등). 길이 미상이면 "".</summary>
public string TotalSizeText { get => _totalSizeText; set => SetField(ref _totalSizeText, value); }
private string _totalSizeText = "";
/// <summary>UC-D09 재시도 횟수.</summary>
public int RetryCount { get => _retryCount; set => SetField(ref _retryCount, value); }
private int _retryCount;
/// <summary>UC-D10 세그먼트별 진행 맵 — 각 세그먼트 진행률(0~1) 목록을 순서대로 유지.</summary>
public System.Collections.ObjectModel.ObservableCollection<double> SegmentMap { get; } = new();
/// <summary>UC-D24 대기열 우선순위(클수록 먼저). 기본 0.</summary>
public int Priority { get => _priority; set => SetField(ref _priority, value); }
private int _priority;
/// <summary>UC-D26 충돌 회피 후 최종 저장 파일명(OutputPath 확정 시 갱신).</summary>
public string? FinalFileName { get => _finalFileName; set => SetField(ref _finalFileName, value); }
private string? _finalFileName;
/// <summary>수신 바이트 총량(속도·ETA 계산 원천). 진행 콜백에서 누적.</summary>
public long ReceivedBytes { get => _receivedBytes; set => SetField(ref _receivedBytes, value); }
private long _receivedBytes;
/// <summary>총 바이트(길이 미상 -1). TotalSizeText 와 ETA 계산에 사용.</summary>
public long TotalBytes { get => _totalBytes; set => SetField(ref _totalBytes, value); }
private long _totalBytes = -1;
/// <summary>UC-D08 완료 시각(자동 치움 지연 판정). 미완료 null.</summary>
public DateTime? CompletedAt { get => _completedAt; set => SetField(ref _completedAt, value); }
private DateTime? _completedAt;
/// <summary>UC-D05 부속: 진행률·수신량으로 속도/ETA 텍스트를 갱신한다. speedBps ≤ 0 이면 속도만 지운다.</summary>
public void UpdateSpeedEta(long receivedBytes, long totalBytes, double speedBps)
{
ReceivedBytes = receivedBytes;
if (totalBytes > 0) TotalBytes = totalBytes;
SpeedText = speedBps <= 0
? ""
: speedBps < 1024 ? $"{speedBps:F0} B/s"
: speedBps < 1024 * 1024 ? $"{speedBps / 1024:F1} KB/s"
: $"{speedBps / 1024 / 1024:F1} MB/s";
if (TotalBytes > 0 && receivedBytes >= 0)
TotalSizeText = $"{TotalBytes / 1048576.0:F1} MB";
RemainingText = speedBps > 0 && TotalBytes > receivedBytes && receivedBytes > 0
? FormatEta(TimeSpan.FromSeconds((TotalBytes - receivedBytes) / speedBps))
: "";
}
/// <summary>ETA 사람 표기(1시간 미만 분 단위, 이상 시 시간 단위).</summary>
public static string FormatEta(TimeSpan eta) => eta.TotalHours >= 1
? $"{(int)eta.TotalHours}시간 {eta.Minutes}분 남음"
: eta.TotalMinutes >= 1
? $"{(int)Math.Ceiling(eta.TotalMinutes)}분 남음"
: $"{(int)Math.Ceiling(eta.TotalSeconds)}초 남음";
public DownloadItemViewModel(string title) { _title = title; }
public event PropertyChangedEventHandler? PropertyChanged;