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

@ -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 메인 창";
}
}