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

@ -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);
}
}