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:
parent
5a0ce730b2
commit
17f2f91152
88 changed files with 9463 additions and 1127 deletions
57
VideoDownloader.App/Services/DownloadScheduler.cs
Normal file
57
VideoDownloader.App/Services/DownloadScheduler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
78
VideoDownloader.App/Services/ShortcutManager.cs
Normal file
78
VideoDownloader.App/Services/ShortcutManager.cs
Normal 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));
|
||||
}
|
||||
40
VideoDownloader.App/Services/ThemeResolver.cs
Normal file
40
VideoDownloader.App/Services/ThemeResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
114
VideoDownloader.App/Services/ToastNotifier.cs
Normal file
114
VideoDownloader.App/Services/ToastNotifier.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue