using System.Collections.ObjectModel; using System.ComponentModel; using System.Windows.Data; using System.Windows; using System.Windows.Automation.Peers; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using Microsoft.Win32; using VideoDownloader.App.Browser.Features; using VideoDownloader.App.Core; using VideoDownloader.App.Services; using VideoDownloader.App.ViewModels; using VideoDownloader.Browser; using VideoDownloader.Browser.Tabs; using VideoDownloader.Core.Config; using TabItem = VideoDownloader.Browser.Tabs.TabItem; namespace VideoDownloader.App; /// /// MainWindow partial — "UI 복잡 버전"(docs/UI_USECASES.md §A~§J) 유즈케이스 구현. /// 기존 MainWindow.xaml.cs 의 뼈대(탭·다운로드·팔레트)는 그대로 두고 /// 신규 컨트롤의 이벤트 처리기·공개 API 를 이 파일에 모은다. /// public partial class MainWindow { // ---- §E/§J 상태 갱신 타이머·서비스 ---- private System.Windows.Threading.DispatcherTimer? _uiTimer; private ToastNotifier? _toast; private ShortcutManager _shortcuts = null!; private readonly Queue _speedHistory = new(); private List _library = new(); private string _librarySort = "date"; private bool _libraryGrid; private TabItem? _volumeTab; private System.Windows.Threading.DispatcherTimer? _clipboardTimer; private DownloadScheduler _scheduler = new(); private string? _lastClipboardHintUrl; /// 생성자에서 호출 — 유즈케이스 컨트롤 초기화·이벤트 배선. private void InitializeUsecases() { _toast = new ToastNotifier(ToastHost) { Duration = TimeSpan.FromMilliseconds(Math.Max(1000, _config.ToastDurationMs)), DoNotDisturb = _config.DoNotDisturb, }; _shortcuts = new ShortcutManager(_config.Shortcuts); // §E 다운로드 완료/실패 → 토스트·폴더 열기 _manager.ItemCompleted += NotifyDownloadCompleted; _manager.ItemFailed += NotifyDownloadFailed; // §J 상태바·배지·요약 갱신(1초) _uiTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _uiTimer.Tick += (_, _) => UpdateStatusAggregate(); _uiTimer.Start(); // UC-N06 클립보드 URL 감지(5초 폴링 — UI 스레드 가벼운 확인) _clipboardTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(5) }; _clipboardTimer.Tick += (_, _) => CheckClipboardHint(); _clipboardTimer.Start(); // UC-N11 네트워크 끊김 상태 표시 System.Net.NetworkInformation.NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; // UC-A04 OS 고대비 전환 감지 → 테마 재적용 SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; // UC-B18/B20 작업공간 표시·전환기 초기화 var names = _config.WorkspaceNames is { Count: > 0 } ? _config.WorkspaceNames : new List { "작업" }; WorkspaceSwitcher.ItemsSource = names; WorkspaceSwitcher.SelectedIndex = 0; WorkspaceIndicator.Text = names[0]; // UC-N16 첫 실행 온보딩(세션 저장 파일 없을 때만) try { Onboarding.Visibility = File.Exists(SessionPath) ? Visibility.Collapsed : Visibility.Visible; } catch { Onboarding.Visibility = Visibility.Collapsed; } // UC-K14 줌 배율 초기 표시 ZoomIndicator.Content = $"{Math.Round((_tabs?.ActiveTab?.View?.ZoomFactor ?? _config.DefaultZoomFactor) * 100)}%"; // UC-L07 컴팩트 모드 복원 + UC-L25 UI 배율 if (_config.CompactMode && !_compactMode) ToggleCompactMode(); ApplyUiScale(_config.UiScale); if (_config.VerticalTabsDefault && !_verticalTabs) ToggleVerticalTabs(); // 탭 컨텍스트 메뉴 확장(잠금·색·그룹·볼륨 — 기존 메뉴는 Tab_ContextMenu 유지) // 사이트별 그룹(UC-D30): 카드 목록 뷰에 그룹화 적용 ApplyDownloadGrouping(); UpdateShortcutHelp(); ApplyStartupPreferences(); } /// 저장된 환경설정(§C/§D/§E/§J)을 시작 시 실제 UI·동작에 반영한다. private void ApplyStartupPreferences() { // UC-L16 툴바 버튼 노출 선택(빈 목록 = 전부 표시) if (_config.ToolbarButtons is { Count: > 0 }) { var map = new Dictionary { ["back"] = NavBack, ["forward"] = NavForward, ["refresh"] = NavRefresh, ["home"] = NavHome, ["bookmarks"] = BookmarksBtn, ["history"] = HistoryBtn, ["downloads"] = DownloadsBtn, ["shield"] = ShieldBtn, ["playlist"] = PlaylistBtn, ["library"] = LibraryBtn, ["pairing"] = PairingBtn, ["link"] = LinkBtn, ["private"] = PrivateBtn, ["compact"] = CompactModeBtn, }; foreach (var (key, btn) in map) btn.Visibility = _config.ToolbarButtons.Contains(key, StringComparer.OrdinalIgnoreCase) ? Visibility.Visible : Visibility.Collapsed; } // UC-L17 북마크바 복원 · UC-L18 탭 스트립 위치 if (_config.BookmarkBarVisible) ToggleBookmarkBar(); ApplyTabStripPosition(_config.TabStripPosition); // UC-B22 탭 제목 두 줄 — 공용 스타일에 줄바꿈·높이 제한 부여 if (_config.TabTitleMultiline && TryFindResource("TabTitleStyle") is Style titleStyle) { titleStyle.Setters.Add(new Setter(TextBlock.TextWrappingProperty, TextWrapping.Wrap)); titleStyle.Setters.Add(new Setter(TextBlock.MaxHeightProperty, 32.0)); } // UC-D21 다운로드 예약 — 시간대 창 게이트 연결 _scheduler = new DownloadScheduler(_config.DownloadScheduleWindow); _manager.DownloadGate = () => _scheduler.CanRunNow(DateTime.Now); if (_scheduler.Enabled) Log($"다운로드 예약: {_scheduler.Window} 시간대에만 받습니다"); // UC-N09 새 버전 배지 — 저장소가 설정돼 있으면 조용히 확인 if (!string.IsNullOrWhiteSpace(_config.UpdateRepo) && _config.UpdateRepo.Contains('/')) _ = CheckUpdateBadgeAsync(_config.UpdateRepo); } /// UC-L18 탭 스트립 위치: top(기본) / bottom(본문과 행 교환) / left(세로 탭). private void ApplyTabStripPosition(string position) { switch ((position ?? "top").ToLowerInvariant()) { case "bottom": Grid.SetRow(TabStripRow, 3); Grid.SetRow(ContentRoot, 2); MainGrid.RowDefinitions[2].Height = new GridLength(1, GridUnitType.Star); MainGrid.RowDefinitions[3].Height = GridLength.Auto; break; case "left": if (!_verticalTabs) ToggleVerticalTabs(); break; default: // top if (_verticalTabs) ToggleVerticalTabs(); Grid.SetRow(TabStripRow, 2); Grid.SetRow(ContentRoot, 3); MainGrid.RowDefinitions[2].Height = GridLength.Auto; MainGrid.RowDefinitions[3].Height = new GridLength(1, GridUnitType.Star); break; } } /// UC-N09 업데이트 가능 배지 표시(실패해도 조용히). private async Task CheckUpdateBadgeAsync(string repo) { try { var current = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; var result = await new VideoDownloader.Core.Update.UpdateChecker().CheckAsync(repo, current, "win-x64"); if (result.IsNewer) { UpdateBadge.Visibility = Visibility.Visible; UpdateBadge.ToolTip = $"새 버전 {result.LatestVersion} 사용 가능"; } } catch { } } // ==================== §A 테마 라이브 전환 ==================== /// UC-T04: 이 창을 다시 만들지 않고 테마를 즉시 재적용한다. public void ApplyTheme(string theme) { App.ApplyTheme(theme, _config.CustomThemePath); App.ApplyAccent(_config.AccentColor); // 테마 사전을 갈아끼우므로 강조색도 다시 반영 UpdateTitleBarTheme(); UpdateServerStatusIcon(); } /// UC-T30: DWM 타이틀바 특성(밝기·모서리)을 현재 테마에 맞게 다시 적용. public void UpdateTitleBarTheme() { try { var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle; int dark = App.IsLightTheme ? 0 : 1; DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int)); int round = 2; DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int)); } catch { } } // ==================== §C 레이아웃 ==================== /// UC-L19: 패널 폭 더블클릭으로 기본값(420px) 복원. public void ResetPanelWidth() { _panelExpandedWidth = 420; ApplyPanelState(false); PersistPanel(); } private void RightSplitter_DoubleClick(object sender, MouseButtonEventArgs e) => ResetPanelWidth(); /// UC-L25 UI 전체 배율(0.5~2.0) — 레이아웃 루트 변환으로 적용. internal void ApplyUiScale(double scale) { scale = Math.Clamp(scale, 0.5, 2.0); MainGrid.LayoutTransform = Math.Abs(scale - 1.0) < 0.01 ? null : new ScaleTransform(scale, scale); } private void CompactModeBtn_Click(object sender, RoutedEventArgs e) => ToggleCompactMode(); // ==================== §D 탭 ==================== /// UC-B10 휠로 탭 전환(설정 켜져 있을 때). private void TabStrip_MouseWheel(object sender, MouseWheelEventArgs e) { if (!_config.TabWheelSwitch) return; if (e.Delta > 0) _tabs.ActivatePrevious(); else _tabs.ActivateNext(); e.Handled = true; } /// UC-B05 탭 호버 미리보기 — ToolTip 에 제목+상태+URL. private void Tab_ToolTipOpening(object sender, ToolTipEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is TabItem tab) fe.ToolTip = TabPreview.From(tab).BuildTooltipText(); } // ---- 탭 드래그 재정렬(UC-B08 삽입 표시기) ---- private TabItem? _dragTab; private void TabItem_DragOver(object sender, DragEventArgs e) { if (_dragTab == null || !e.Data.GetDataPresent(typeof(TabItem))) return; e.Effects = DragDropEffects.Move; ShowInsertIndicator(e.GetPosition(TabList).X); e.Handled = true; } private void TabItem_Drop(object sender, DragEventArgs e) { if ((sender as FrameworkElement)?.DataContext is TabItem target && _dragTab != null && !ReferenceEquals(target, _dragTab)) _tabs.Move(_dragTab, _tabs.Tabs.IndexOf(target)); CompleteTabDrag(); } private void TabList_DragOver(object sender, DragEventArgs e) => ShowInsertIndicator(e.GetPosition(TabList).X); private void TabList_Drop(object sender, DragEventArgs e) { if (_dragTab != null) { var idx = IndexAtX(e.GetPosition(TabList).X); if (idx >= 0) _tabs.Move(_dragTab, idx); } CompleteTabDrag(); } private void TabList_DragLeave(object sender, DragEventArgs e) => TabInsertIndicator.Visibility = Visibility.Collapsed; private void CompleteTabDrag() { TabInsertIndicator.Visibility = Visibility.Collapsed; _dragTab = null; } private void ShowInsertIndicator(double x) { var idx = IndexAtX(x); if (idx < 0) return; TabInsertIndicator.Visibility = Visibility.Visible; TabInsertIndicator.Margin = new Thickness(Math.Max(0, idx * 138), 0, 0, 0); // 항목 폭+여백 근사 } /// x 좌표로 삽입 인덱스 계산(탭 폭 근사 138px). private int IndexAtX(double x) => Math.Clamp((int)(x / 138), 0, Math.Max(0, _tabs.Tabs.Count)); /// 탭 번호 배지(UC-B07)·잠금 아이콘 갱신 — 탭 목록 변화 시 코드에서 호출. internal void RefreshTabBadges() { for (var i = 0; i < _tabs.Tabs.Count; i++) { if (TabList.ItemContainerGenerator.ContainerFromIndex(i) is ContentPresenter cp) UpdateTabBadge(cp, i); } } private void UpdateTabBadge(ContentPresenter cp, int index) { var badge = FindVisualChildByName(cp, "TabIndexBadge"); var text = FindVisualChildByName(cp, "TabIndexText"); if (badge is UIElement b && text is TextBlock tb) { b.Visibility = _tabs.Tabs.Count > 6 ? Visibility.Visible : Visibility.Collapsed; // 탭 많을 때만 tb.Text = (index + 1).ToString(); } } private static System.Windows.DependencyObject? FindVisualChildByName(System.Windows.DependencyObject parent, string name) { if (parent is FrameworkElement fe && fe.Name == name) return parent; for (var i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); i++) { var r = FindVisualChildByName(System.Windows.Media.VisualTreeHelper.GetChild(parent, i), name); if (r != null) return r; } return null; } /// UC-B21 새 탭 보조 메뉴 — 최근 페이지·즐겨찾기로 바로 열기. private void NewTabMore_Click(object sender, RoutedEventArgs e) { var menu = new ContextMenu(); var recent = new MenuItem { Header = "최근 페이지" }; foreach (var h in _history.Take(6)) { var mi = new MenuItem { Header = Trunc(h.Title, 34) }; var url = h.Url; mi.Click += (_, _) => { var t = _tabs.NewTab(); Cv2Navigate(t, url); }; recent.Items.Add(mi); } if (recent.Items.Count == 0) recent.Items.Add(new MenuItem { Header = "(기록 없음)", IsEnabled = false }); menu.Items.Add(recent); var favs = new MenuItem { Header = "즐겨찾기" }; foreach (var b in _bookmarks.Take(6)) { var mi = new MenuItem { Header = Trunc(b.Title, 34) }; var url = b.Url; mi.Click += (_, _) => { var t = _tabs.NewTab(); Cv2Navigate(t, url); }; favs.Items.Add(mi); } if (favs.Items.Count == 0) favs.Items.Add(new MenuItem { Header = "(즐겨찾기 없음)", IsEnabled = false }); menu.Items.Add(favs); var arch = new MenuItem { Header = "닫은 탭 아카이브" }; arch.Click += (_, _) => ArchiveBtn_Click(sender, e); menu.Items.Add(arch); menu.PlacementTarget = sender as UIElement; menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom; menu.IsOpen = true; } /// UC-B24 그룹 전체를 즐겨찾기로 저장. public void BookmarkGroup(string? groupName = null) { var name = groupName ?? _tabs.ActiveTab?.GroupName; if (string.IsNullOrEmpty(name)) { Log("그룹에 속한 탭에서 실행하세요"); return; } var added = 0; foreach (var t in _tabs.Tabs.Where(t => t.GroupName == name).ToList()) { if (_bookmarks.Any(b => b.Url == t.Url)) continue; _bookmarks.Add(new Bookmark(t.Title, t.Url)); added++; } BrowserStore.SaveBookmarks(_bookmarks.ToList()); RefreshBookmarkBar(); _toast?.Show($"그룹 '{name}' 즐겨찾기 {added}건 저장", "success"); } // ---- 아카이브 팝업(UC-B13~B16) ---- private void ArchiveBtn_Click(object sender, RoutedEventArgs e) { ArchiveSearchBox.Text = ""; RefreshArchiveList(""); ArchivePopup.IsOpen = true; } private void ArchiveSearchBox_TextChanged(object sender, TextChangedEventArgs e) => RefreshArchiveList(ArchiveSearchBox.Text); private void RefreshArchiveList(string query) { ArchiveList.ItemsSource = _tabArchive.Search(query).Take(50) .Select(a => new ArchivedTab(a.Title, a.Url, a.ClosedAt)); } private void ArchiveRestore_Click(object sender, RoutedEventArgs e) { if ((sender as FrameworkElement)?.DataContext is not ArchivedTab hit) return; var restored = _tabArchive.Restore(hit.Url); var tab = _tabs.NewTab(); Cv2Navigate(tab, hit.Url); RefreshArchiveList(ArchiveSearchBox.Text); ArchivePopup.IsOpen = false; Log($"아카이브 복구: {restored?.Title ?? hit.Title}"); } private void ArchiveClear_Click(object sender, RoutedEventArgs e) { if (MessageBox.Show(this, "닫은 탭 아카이브를 전부 비울까요?", "아카이브", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK) return; _tabArchive.Clear(); RefreshArchiveList(""); } /// UC-B18/B20 작업공간 전환. private void WorkspaceSwitcher_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (WorkspaceSwitcher.SelectedItem is not string name) return; WorkspaceIndicator.Text = name; _currentWorkspaceName = name; // 같은 이름의 저장된 워크스페이스가 있으면 전환, 없으면 이름만 표시(라우팅 기준) var ws = _workspaces.FirstOrDefault(w => w.Name == name); if (ws != null && _tabs.Tabs.Count > 0) SwitchWorkspace(ws); } /// UC-B23 탭 볼륨 슬라이더 — 페이지 내 미디어 볼륨을 JS 로 제어. 탭 컨텍스트 메뉴에서 호출. internal void OpenTabVolume(TabItem tab) { _volumeTab = tab; TabVolumeSlider.Value = 100; TabVolumeValue.Text = "100%"; TabVolumePopup.IsOpen = true; } private void TabVolumeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) { if (TabVolumeValue == null) return; TabVolumeValue.Text = $"{(int)e.NewValue}%"; var cv = _volumeTab?.Core; if (cv == null) return; var v = e.NewValue / 100.0; try { _ = cv.ExecuteScriptAsync( $"document.querySelectorAll('video,audio').forEach(function(m){{try{{m.volume={v.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}catch(e){{}}}});"); } catch { } } // ==================== §E 다운로드 관리자 ==================== /// UC-D30 사이트별 묶음 — 카드 목록을 출처 호스트로 그룹핑한 뷰로 교체. private void ApplyDownloadGrouping() { var view = CollectionViewSource.GetDefaultView(_manager.Items); if (!view.GroupDescriptions.OfType().Any(g => g.PropertyName == "SiteName")) view.GroupDescriptions.Add(new PropertyGroupDescription("SiteName")); DownloadCards.GroupStyle.Add(new GroupStyle { HeaderTemplate = (DataTemplate)Resources["SiteGroupHeaderTemplate"], }); } // UC-D02 필터 + UC-D03 검색 조합 뷰 private void ApplyDownloadFilter() { if (_manager == null || DownloadSearchBox == null) return; // XAML 로드 중 이벤트 방어 var view = CollectionViewSource.GetDefaultView(_manager.Items); var q = DownloadSearchBox?.Text?.Trim() ?? ""; view.Filter = o => { if (o is not DownloadItemViewModel it) return false; if (q.Length > 0 && !it.Title.Contains(q, StringComparison.OrdinalIgnoreCase) && !(it.MediaUrl?.ToString().Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)) return false; if (FilterActive?.IsChecked == true) return it.Status is "대기" or "다운로드 중" or "병합 중" or "리먹스 중" or "일시정지" or "예약 대기"; if (FilterDone?.IsChecked == true) return it.Status == "완료"; if (FilterFailed?.IsChecked == true) return it.Status is "오류" or "취소됨"; return true; }; // UC-D01 정렬 순서 적용(비교자는 1회만 — 매 입력마다 중복 추가 방지) if (view is System.Windows.Data.ListCollectionView lcv && lcv.CustomSort == null) lcv.CustomSort = new DownloadSortComparer(_config.DownloadSortOrder); view.Refresh(); } /// UC-D01 다운로드 정렬 비교자 — newest(기본 삽입순)/largest/progress/title. private sealed class DownloadSortComparer : System.Collections.IComparer { private readonly string _order; public DownloadSortComparer(string order) => _order = order; public int Compare(object? x, object? y) { if (x is not DownloadItemViewModel a || y is not DownloadItemViewModel b) return 0; return _order switch { "largest" => b.TotalBytes.CompareTo(a.TotalBytes), "progress" => b.Progress.CompareTo(a.Progress), "title" => string.Compare(a.Title, b.Title, StringComparison.CurrentCultureIgnoreCase), _ => 0, // newest: ObservableCollection 삽입 역순이 곧 최신순 }; } } private void DownloadFilter_Changed(object sender, RoutedEventArgs e) => ApplyDownloadFilter(); private void DownloadSearchBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyDownloadFilter(); /// UC-D06 미디어+페이지 다운로드 전체 일시정지. public void PauseAllDownloads() { _manager.PauseAll(); _pageDownloads.PauseAll(); Log("모두 일시정지 — 대기·페이지 다운로드가 정지됩니다"); } /// UC-D07 전체 재개. public void ResumeAllDownloads() { _manager.ResumeAll(); _pageDownloads.ResumeAll(); Log("모두 재개"); } private void PauseAllDownloads_Click(object sender, RoutedEventArgs e) => PauseAllDownloads(); private void ResumeAllDownloads_Click(object sender, RoutedEventArgs e) => ResumeAllDownloads(); /// UC-D23 목록을 CSV 로 내보내기. public string? ExportDownloads() { var dlg = new SaveFileDialog { Filter = "CSV|*.csv", FileName = $"downloads-{DateTime.Now:yyyyMMdd}.csv" }; if (dlg.ShowDialog() != true) return null; var sb = new System.Text.StringBuilder(); sb.AppendLine("제목,상태,진행률,형식,URL,저장경로"); foreach (var it in _manager.Items) sb.AppendLine($"{Csv(it.Title)},{Csv(it.Status)},{it.Progress}%,{Csv(it.FormatTag)},{Csv(it.MediaUrl.ToString())},{Csv(it.OutputPath ?? "")}"); File.WriteAllText(dlg.FileName, sb.ToString(), new System.Text.UTF8Encoding(true)); _toast?.Show($"다운로드 목록 내보냄: {dlg.FileName}", "success"); return dlg.FileName; } private static string Csv(string s) => $"\"{(s ?? "").Replace("\"", "\"\"")}\""; private void ExportDownloads_Click(object sender, RoutedEventArgs e) => ExportDownloads(); /// UC-D25 중복 다운로드 확인 — 같은 URL 카드가 이미 있거나 파일이 존재하면 물어본다. public bool ConfirmDuplicateDownload(DownloadItemViewModel item) { var dupCard = _manager.Items.Any(i => !ReferenceEquals(i, item) && i.MediaUrl == item.MediaUrl); var fileExists = OutExtsAny(item.OutputBase); if (!dupCard && !fileExists) return true; var reason = dupCard ? "같은 영상이 목록에 있습니다" : "같은 이름의 파일이 저장 폴더에 있습니다"; return MessageBox.Show(this, $"{reason}\n그래도 받을까요?", "중복 다운로드", MessageBoxButton.OKCancel, MessageBoxImage.Question) == MessageBoxResult.OK; } private static bool OutExtsAny(string base_) => new[] { ".mp4", ".ts", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".wav", ".flac" } .Any(ext => File.Exists(base_ + ext)); // ---- 카드 컨텍스트 메뉴(UC-D14) ---- private void CardCopyUrl_Click(object sender, RoutedEventArgs e) { if (CardItem(sender) is { } it) { try { Clipboard.SetText(it.MediaUrl.ToString()); } catch { } } } private void CardCopyInfo_Click(object sender, RoutedEventArgs e) { if (CardItem(sender) is { } it) { try { Clipboard.SetText($"{it.Title}\n{it.MediaUrl}\n{it.OutputPath ?? ""}"); } catch { } } } private void CardDismiss_Click(object sender, RoutedEventArgs e) { if (CardItem(sender) is { } it) { _manager.Cancel(it); _manager.Items.Remove(it); } } private void CardPriorityUp_Click(object sender, RoutedEventArgs e) { if (CardItem(sender) is { } it) { it.Priority++; Log($"우선순위 {it.Priority}"); } } private void CardPriorityDown_Click(object sender, RoutedEventArgs e) { if (CardItem(sender) is { } it && it.Priority > 0) it.Priority--; } /// UC-D27 오류 메시지 복사. private void CardCopyError_Click(object sender, RoutedEventArgs e) { if (CardItem(sender) is { } it && !string.IsNullOrEmpty(it.Error)) { try { Clipboard.SetText(it.Error); _toast?.Show("오류 메시지를 복사했습니다", "info"); } catch { } } } private static DownloadItemViewModel? CardItem(object sender) => (sender as FrameworkElement)?.DataContext as DownloadItemViewModel; // ==================== §I 라이브러리 ==================== /// 라이브러리 항목 — 받은 파일 한 건(UC-M01). public sealed record LibraryEntry(string Name, string Path, long Size, DateTime AddedAt) { public string SizeText => Size >= 0 ? $"{Size / 1048576.0:F1} MB" : ""; } private void LibraryBtn_Click(object sender, RoutedEventArgs e) { RefreshLibrary(); LibraryPopup.IsOpen = true; } private void RefreshLibrary() { var q = LibrarySearchBox?.Text?.Trim() ?? ""; IEnumerable items; try { var dir = Path.GetFullPath(_config.OutputFolder); items = Directory.Exists(dir) ? Directory.EnumerateFiles(dir) .Where(f => !f.EndsWith(".part", StringComparison.OrdinalIgnoreCase) && !f.EndsWith(".log", StringComparison.OrdinalIgnoreCase)) .Select(f => new LibraryEntry(Path.GetFileName(f), f, new FileInfo(f).Length, File.GetCreationTime(f))) : Enumerable.Empty(); } catch { items = Enumerable.Empty(); } if (q.Length > 0) items = items.Where(i => i.Name.Contains(q, StringComparison.OrdinalIgnoreCase)); items = _librarySort switch { "name" => items.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase), "size" => items.OrderByDescending(i => i.Size), _ => items.OrderByDescending(i => i.AddedAt), }; _library = items.Take(200).ToList(); LibraryList.ItemsSource = _library; LibraryEmptyHint.Visibility = _library.Count == 0 ? Visibility.Visible : Visibility.Collapsed; } private void LibrarySearchBox_TextChanged(object sender, TextChangedEventArgs e) => RefreshLibrary(); private void LibrarySort_Click(object sender, RoutedEventArgs e) { _librarySort = (sender as MenuItem)?.Tag as string ?? "date"; RefreshLibrary(); } /// UC-M02 그리드/리스트 보기 전환. private void LibraryViewToggle_Click(object sender, RoutedEventArgs e) { _libraryGrid = !_libraryGrid; LibraryViewToggle.Content = _libraryGrid ? "\uE8FD; 그리드" : "\uE71B; 리스트"; RefreshLibrary(); } /// UC-M07 더블클릭 재생. private void LibraryItem_DoubleClick(object sender, MouseButtonEventArgs e) { if (e.ChangedButton == MouseButton.Left && e.ClickCount >= 2 && CardItem(sender) == null && (sender as FrameworkElement)?.DataContext is LibraryEntry entry) { PlayLibraryEntry(entry); e.Handled = true; } } private void PlayLibraryEntry(LibraryEntry entry) { try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(entry.Path) { UseShellExecute = true }); } catch (Exception ex) { Log($"재생 실패: {ex.Message}"); } } // ---- 재생목록 확장(M11~M15) ---- private List PlaylistView() => _playlist.Tracks.ToList(); private void PlaylistSearchBox_TextChanged(object sender, TextChangedEventArgs e) { var q = PlaylistSearchBox?.Text?.Trim() ?? ""; var view = CollectionViewSource.GetDefaultView(_playlist.Tracks); view.Filter = q.Length == 0 ? null : o => o is MediaTrack t && (t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) || t.Url.Contains(q, StringComparison.OrdinalIgnoreCase)); } /// UC-M11 셔플 재생. private void PlaylistShuffle_Click(object sender, RoutedEventArgs e) { var list = PlaylistView(); if (list.Count == 0) return; var rng = new Random(); var shuffled = list.OrderBy(_ => rng.Next()).ToList(); _playlist.Clear(); foreach (var t in shuffled) _playlist.Add(t); SavePlaylist(); OpenPlayer(); } /// UC-M12 반복 모드 순환(끔 → 전체 → 한곡). private void PlaylistLoop_Click(object sender, RoutedEventArgs e) { _config.PlaylistLoopMode = _config.PlaylistLoopMode switch { "none" => "all", "all" => "one", _ => "none", }; ConfigStore.Save(_config); PlaylistLoop.Content = _config.PlaylistLoopMode switch { "all" => "전체 반복", "one" => "한곡 반복", _ => "반복 끔", }; } private void SavePlaylist() { try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } } private void PlaylistImport_Click(object sender, RoutedEventArgs e) { var dlg = new OpenFileDialog { Filter = "M3U 재생목록|*.m3u" }; if (dlg.ShowDialog() != true) return; try { foreach (var line in File.ReadAllLines(dlg.FileName)) { var url = line.Trim(); if (url.Length == 0 || url.StartsWith('#')) continue; AddToPlaylist(url, GuessTitle(url), "file", null); } } catch (Exception ex) { Log($"재생목록 불러오기 실패: {ex.Message}"); } } /// UC-M14 순서 변경. private void PlaylistMoveUp_Click(object sender, RoutedEventArgs e) => PlaylistMove(sender, -1); private void PlaylistMoveDown_Click(object sender, RoutedEventArgs e) => PlaylistMove(sender, +1); private void PlaylistMove(object sender, int delta) { if ((sender as FrameworkElement)?.DataContext is not MediaTrack t) return; var list = PlaylistView(); var idx = list.IndexOf(t); var next = idx + delta; if (idx < 0 || next < 0 || next >= list.Count) return; _playlist.Clear(); list.RemoveAt(idx); list.Insert(next, t); foreach (var x in list) _playlist.Add(x); SavePlaylist(); } // ---- 즐겨찾기·기록·노트 확장(M16~M22) ---- private void BookmarkSearchBox_TextChanged(object sender, TextChangedEventArgs e) { var q = BookmarkSearchBox?.Text?.Trim() ?? ""; var view = CollectionViewSource.GetDefaultView(_bookmarks); view.Filter = q.Length == 0 ? null : o => o is Bookmark b && (b.Title.Contains(q, StringComparison.OrdinalIgnoreCase) || b.Url.Contains(q, StringComparison.OrdinalIgnoreCase)); } /// UC-K16 즐겨찾기 관리 창. private void BookmarkManager_Click(object sender, RoutedEventArgs e) { BookmarksPopup.IsOpen = false; new BookmarkManagerWindow(_bookmarks, () => { BrowserStore.SaveBookmarks(_bookmarks.ToList()); RefreshBookmarkBar(); }) { Owner = this }.ShowDialog(); } /// UC-M19 브라우저 호환 HTML 즐겨찾기 내보내기. public string? ExportBookmarks() { var dlg = new SaveFileDialog { Filter = "북마크 HTML|*.html", FileName = "bookmarks.html" }; if (dlg.ShowDialog() != true) return null; var sb = new System.Text.StringBuilder(); sb.AppendLine(""); sb.AppendLine("Bookmarks

Bookmarks

"); foreach (var b in _bookmarks) sb.AppendLine($"

{System.Net.WebUtility.HtmlEncode(b.Title)}"); sb.AppendLine("

"); File.WriteAllText(dlg.FileName, sb.ToString()); _toast?.Show("즐겨찾기를 내보냈습니다", "success"); return dlg.FileName; } private string? Prompt(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 = (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 = (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; } ///

UC-M20 방문 기록 날짜별 묶음 뷰 — 같은 날 첫 항목에만 헤더 표시. private List GroupHistoryByDate(IEnumerable history) { var rows = new List(); foreach (var g in history.GroupBy(h => h.VisitedAt.Date)) { var key = g.Key == DateTime.Today ? "오늘" : g.Key == DateTime.Today.AddDays(-1) ? "어제" : g.Key.ToString("yyyy-MM-dd ddd"); var first = true; foreach (var h in g) { rows.Add(new HistoryRow(h.Title, h.Url, h.VisitedAt, key, first)); first = false; } } return rows; } /// 기록 행 뷰(날짜 묶음 헤더 표시용). public sealed record HistoryRow(string Title, string Url, DateTime VisitedAt, string GroupKey, bool ShowGroupKey); private void HistorySearchBox_TextChanged(object sender, TextChangedEventArgs e) { var q = HistorySearchBox?.Text?.Trim() ?? ""; var view = CollectionViewSource.GetDefaultView(HistoryList.ItemsSource); if (view == null) return; view.Filter = q.Length == 0 ? null : o => o is HistoryRow r && (r.Title.Contains(q, StringComparison.OrdinalIgnoreCase) || r.Url.Contains(q, StringComparison.OrdinalIgnoreCase)); } /// UC-K17 이 항목만 기록에서 삭제. private void DeleteHistory_Click(object sender, RoutedEventArgs e) { if ((sender as FrameworkElement)?.DataContext is not HistoryRow row) return; var src = _history.FirstOrDefault(h => h.Url == row.Url); if (src != null) _history.Remove(src); BrowserStore.SaveHistory(_history.ToList()); HistoryList.ItemsSource = GroupHistoryByDate(_history); } private void NotesSearchBox_TextChanged(object sender, TextChangedEventArgs e) { var q = NotesSearchBox?.Text?.Trim() ?? ""; var view = CollectionViewSource.GetDefaultView(NotesList.ItemsSource); if (view == null) return; view.Filter = q.Length == 0 ? null : o => o is string s && s.Contains(q, StringComparison.OrdinalIgnoreCase); } // ==================== §H 인터랙션 ==================== /// UC-K10 Esc 로 열린 모든 팝업 일괄 닫기. private void CloseAllPopups() { foreach (var p in new Popup[] { BookmarksPopup, HistoryPopup, AddressPopup, NotesPopup, ShieldPopup, DownloadsPopup, TabSearchPopup, PalettePopup, PlaylistPopup, LinkPopup, LibraryPopup, ArchivePopup, ShortcutHelpPopup, NotificationCenterPopup, TabVolumePopup, }) { try { p.IsOpen = false; } catch { } } if (FindBar.Visibility == Visibility.Visible) CloseFind(); if (ErrorBanner.Visibility == Visibility.Visible) ErrorBanner.Visibility = Visibility.Collapsed; } /// UC-K12 F12 개발자 도구. private void OpenDevTools() { try { Cv?.OpenDevToolsWindow(); } catch { Log("개발자 도구를 열 수 없습니다"); } } /// UC-K13 프로필(세션) 전환 — 별도 WebView2 프로필 폴더로 재시작. private void SwitchProfile() { var next = Environment.GetEnvironmentVariable("VD_PROFILE") == "2" ? null : "2"; var msg = next == "2" ? "프로필 2(새 세션)로 전환합니다.\n앱이 재시작되며 현재 탭은 닫힙니다." : "기본 프로필로 전환합니다.\n앱이 재시작되며 현재 탭은 닫힙니다."; if (MessageBox.Show(this, msg + "\n\n계속할까요?", "프로필 전환", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK) return; Environment.SetEnvironmentVariable("VD_PROFILE", next, EnvironmentVariableTarget.User); RestartApp(); } /// 프로필 전환용 폴더(ProfileFolder 확장). internal static string ProfileFolderFor(string? profile) => profile == "2" ? Path.Combine(AppDataDir, "webview2-profile2") : ProfileFolder; /// UC-K18 화면 영역 지정 캡처 — 전체 화면 캡처 후 사용자가 드래그한 영역을 잘라 저장. private async Task RegionCapture() { try { var sw = System.Diagnostics.Stopwatch.StartNew(); // 1) 현재 모니터 캡처(TakeScreenshot 와 동일 경로 — DataStream 캡처) var tab = _tabs.ActiveTab; var cv = tab?.Core; if (cv == null) { Log("캡처할 페이지가 없습니다"); return; } using var ms = new MemoryStream(); await cv.CapturePreviewAsync(Microsoft.Web.WebView2.Core.CoreWebView2CapturePreviewImageFormat.Png, ms); ms.Position = 0; var full = System.Windows.Media.Imaging.BitmapFrame.Create(ms, System.Windows.Media.Imaging.BitmapCreateOptions.None, System.Windows.Media.Imaging.BitmapCacheOption.OnLoad); // 2) 반투명 오버레이로 영역 선택 var overlay = new Window { WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = new SolidColorBrush(Color.FromArgb(0x40, 0, 0, 0)), WindowState = WindowState.Maximized, Topmost = true, ShowInTaskbar = false, }; var canvas = new Canvas(); var rect = new System.Windows.Shapes.Rectangle { Stroke = (Brush)FindResource("AccentBrush"), StrokeThickness = 2, StrokeDashArray = new DoubleCollection { 4, 2 }, }; canvas.Children.Add(rect); overlay.Content = canvas; System.Windows.Point? start = null; canvas.MouseLeftButtonDown += (_, me) => { start = me.GetPosition(overlay); rect.StrokeDashArray = null; }; canvas.MouseMove += (_, me) => { if (start is { } s) { var p = me.GetPosition(overlay); Canvas.SetLeft(rect, Math.Min(s.X, p.X)); Canvas.SetTop(rect, Math.Min(s.Y, p.Y)); rect.Width = Math.Abs(p.X - s.X); rect.Height = Math.Abs(p.Y - s.Y); } }; var tcs = new TaskCompletionSource(); canvas.MouseLeftButtonUp += (_, me) => { if (start is { } s) { var p = me.GetPosition(overlay); var r = new Rect(s, p); tcs.TrySetResult(r.Width > 4 && r.Height > 4 ? r : null); } else tcs.TrySetResult(null); }; canvas.MouseRightButtonDown += (_, _) => tcs.TrySetResult(null); overlay.KeyDown += (_, ke) => { if (ke.Key == Key.Escape) tcs.TrySetResult(null); }; overlay.Show(); var area = await tcs.Task; overlay.Close(); if (area == null) { Log("영역 캡처 취소"); return; } // 3) 영역 크롭(DPI 고려) 저장 var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this); var src = new System.Windows.Media.Imaging.CroppedBitmap(full, new System.Windows.Int32Rect( (int)(area.Value.X * dpi.DpiScaleX), (int)(area.Value.Y * dpi.DpiScaleY), Math.Max(1, (int)(area.Value.Width * dpi.DpiScaleX)), Math.Max(1, (int)(area.Value.Height * dpi.DpiScaleY)))); var file = Path.Combine(_config.OutputFolder, $"capture-{DateTime.Now:yyyyMMdd-HHmmss}.png"); using var fs = File.Create(file); System.Windows.Media.Imaging.BitmapEncoder enc = new System.Windows.Media.Imaging.PngBitmapEncoder(); enc.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(src)); enc.Save(fs); Log($"영역 캡처 저장: {file} ({sw.ElapsedMilliseconds}ms)"); } catch (Exception ex) { Log($"영역 캡처 실패: {ex.Message}"); } } /// UC-K07 주소창 컨텍스트 "붙여넣고 이동". private void PasteAndGo_Click(object sender, RoutedEventArgs e) { try { var text = Clipboard.GetText().Trim(); if (text.Length > 0) NavigateTo(text); } catch { } } /// UC-K05 마우스 제스처 — XButton1(측면 뒤로)/XButton2(앞으로) 로 탐색. private void Window_PreviewMouseDown(object sender, MouseButtonEventArgs e) { var cv = Cv; if (cv == null) return; switch (e.ChangedButton) { case MouseButton.XButton1: try { if (cv.CanGoBack) cv.GoBack(); } catch { } e.Handled = true; break; case MouseButton.XButton2: try { if (cv.CanGoForward) cv.GoForward(); } catch { } e.Handled = true; break; } } /// UC-K06 휠 클릭(가운데 버튼) 링크 → 새 탭. 페이지에 스크립트를 심어 /// auxclick(middle) 이벤트를 window.chrome.webmessage 로 전달받는다. internal void WireTabMouseGestures(Microsoft.Web.WebView2.Core.CoreWebView2 cv) { const string script = """ (function(){ if (window.__vdGesture) return; window.__vdGesture = true; document.addEventListener('auxclick', function(ev){ if (ev.button !== 1) return; var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null; if (!a) return; ev.preventDefault(); window.chrome.webview.postMessage({ __vd:'middleclick', url: a.href }); }, true); })(); """; try { _ = cv.AddScriptToExecuteOnDocumentCreatedAsync(script); } catch { } cv.WebMessageReceived += (s, we) => { try { var json = System.Text.Json.JsonDocument.Parse(we.WebMessageAsJson).RootElement; if (json.TryGetProperty("__vd", out var kind) && kind.GetString() == "middleclick" && json.TryGetProperty("url", out var url) && url.GetString() is { Length: > 0 } link) { Dispatcher.BeginInvoke(new Action(() => { var tab = _tabs.NewTab(activate: false); Cv2Navigate(tab, link); Log($"휠 클릭으로 새 탭: {Trunc(link, 60)}"); })); } } catch { } }; } // ---- 탭 드래그 재정렬 시작(템플릿 TabBg.MouseMove) ---- private void TabBg_MouseMove(object sender, MouseEventArgs e) { if (sender is not FrameworkElement fe || _dragTab != null) return; if (e.LeftButton != MouseButtonState.Pressed || fe.DataContext is not TabItem tab) return; _dragTab = tab; try { DragDrop.DoDragDrop(fe, new DataObject(typeof(TabItem), tab), DragDropEffects.Move); } catch { _dragTab = null; } } /// UC-K08 주소창 포커스 시 전체 선택(설정 켜져 있을 때). private void AddressBox_GotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) { if (_config.FocusSelectsAddress) AddressBox.SelectAll(); } private void ZoomIndicator_Click(object sender, RoutedEventArgs e) { ZoomSet(1.0); ZoomIndicator.Content = "100%"; } // ==================== §J 상태·알림 ==================== /// 상태바 집계 갱신 — 통합 진행률·요약·전체 속도·스파크라인·배지. private void UpdateStatusAggregate() { try { var items = _manager.Items.ToList(); var active = items.Where(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중").ToList(); var pending = items.Count(i => i.Status == "대기"); var failed = items.Count(i => i.Status == "오류"); var done = items.Count(i => i.Status == "완료"); QueueSummaryText.Text = items.Count == 0 ? "" : $"{pending} 대기 · {active.Count} 진행 · {done} 완료{(failed > 0 ? $" · {failed} 실패" : "")}"; DownloadsBtnBadge.Visibility = active.Count > 0 ? Visibility.Visible : Visibility.Collapsed; DownloadsBtnBadgeText.Text = active.Count.ToString(); var avg = active.Count == 0 ? 0 : (int)active.Average(i => i.Progress); AggregateProgress.Value = active.Count > 0 ? avg : (done > 0 ? 100 : 0); var bps = _pageDownloads.Items.Sum(i => i.BytesPerSecond); GlobalSpeedText.Text = bps <= 0 ? "" : bps < 1024 * 1024 ? $"{bps / 1024:F0} KB/s" : $"{bps / 1024.0 / 1024:F1} MB/s"; _speedHistory.Enqueue(bps); while (_speedHistory.Count > 10) _speedHistory.Dequeue(); var max = Math.Max(1024, _speedHistory.Max()); SpeedSparkline.Points = new PointCollection( _speedHistory.Select((v, i) => new Point(i * 12, 12 - Math.Clamp(v * 12.0 / max, 0, 12)))); RefreshTabBadges(); // UC-B07 탭 번호 배지 // UC-D08 완료 항목 자동 치움(완료 후 5초) if (_config.AutoClearCompleted) foreach (var gone in items.Where(i => i.CompletedAt is { } at && (DateTime.UtcNow - at).TotalSeconds > 5).ToList()) _manager.Items.Remove(gone); // UC-L15 상태바 자동 숨김 — 마우스가 올라와 있지 않고 활성 작업도 없으면 접는다 if (_config.StatusBarAutoHide && StatusBar != null) StatusBar.Visibility = StatusBar.IsMouseOver || active.Count > 0 ? Visibility.Visible : Visibility.Collapsed; } catch { } } /// 완료/오류를 토스트로 알림(DownloadManager 이벤트에서 호출). internal void NotifyDownloadCompleted(DownloadItemViewModel it) { _toast?.Show($"완료: {Trunc(it.Title, 40)}", "success"); if (_config.NotifySound && !_config.DoNotDisturb) try { System.Media.SystemSounds.Asterisk.Play(); } catch { } if (_config.OpenFolderOnComplete) OpenInExplorer(it.OutputPath); } internal void NotifyDownloadFailed(DownloadItemViewModel it) => _toast?.Show($"실패: {Trunc(it.Title, 40)}", "error"); private void ErrorBanner_Close(object sender, RoutedEventArgs e) => ErrorBanner.Visibility = Visibility.Collapsed; /// 치명 오류 배너 표시(스택 포함). internal void ShowErrorBanner(string message, Exception? ex = null) { ErrorDetailText.Text = ex == null ? message : $"{message}\r\n{ex}"; ErrorBanner.Visibility = Visibility.Visible; } private void Onboarding_Close(object sender, RoutedEventArgs e) => Onboarding.Visibility = Visibility.Collapsed; /// UC-N06 클립보드에 미디어/소셜 URL 이 있으면 받기 제안 배지. private void CheckClipboardHint() { try { if (Keyboard.FocusedElement is TextBox || LinkPopup.IsOpen) return; var text = Clipboard.ContainsText() ? Clipboard.GetText().Trim() : ""; bool isUrl = text.StartsWith("http", StringComparison.OrdinalIgnoreCase) && Uri.TryCreate(text, UriKind.Absolute, out _); var hintUrl = isUrl ? text : null; if (hintUrl == _lastClipboardHintUrl) return; _lastClipboardHintUrl = hintUrl; ClipboardHint.Visibility = hintUrl != null && text.Length < 2048 ? Visibility.Visible : Visibility.Collapsed; } catch { } } private void ClipboardHint_Click(object sender, RoutedEventArgs e) { ClipboardHint.Visibility = Visibility.Collapsed; if (_lastClipboardHintUrl == null) return; AddressBox.Text = _lastClipboardHintUrl; NavigateTo(_lastClipboardHintUrl); } private void OnNetworkAvailabilityChanged(object? sender, System.Net.NetworkInformation.NetworkAvailabilityEventArgs e) { Dispatcher.BeginInvoke(new Action(() => { ServerStatusIcon.ToolTip = e.IsAvailable ? "네트워크 연결됨" : "네트워크 끊김 — 오프라인"; ServerStatusIcon.Foreground = e.IsAvailable ? (Brush)FindResource("SuccessBrush") : (Brush)FindResource("ErrorBrush"); if (!e.IsAvailable) Log("네트워크 연결이 끊겼습니다"); })); } private void PairingBtn_Click(object sender, RoutedEventArgs e) => ShowPairing(); /// 서버 상태 아이콘 갱신(원격 접근 토글 후 호출). private void UpdateServerStatusIcon() { Dispatcher.BeginInvoke(new Action(() => { ServerStatusIcon.Foreground = _remoteServer != null ? (Brush)FindResource("SuccessBrush") : (Brush)FindResource("TextMuted"); ServerStatusIcon.ToolTip = _remoteServer != null ? $"원격 접속 켜짐 — 포트 {_remoteServer.Port}" : "원격 접속 꺼짐"; })); } private void NotificationCenter_Click(object sender, RoutedEventArgs e) { NotificationCenterList.ItemsSource = _toast?.History.ToList() ?? new List(); NotificationCenterPopup.IsOpen = true; } private void ShortcutHelpBtn_Click(object sender, RoutedEventArgs e) => ShortcutHelpPopup.IsOpen = true; /// 단축키 도움말 목록 갱신(사용자 재배치 반영). private void UpdateShortcutHelp() { var labels = new Dictionary { ["newtab"] = "새 탭", ["closetab"] = "탭 닫기", ["reopentab"] = "닫은 탭 복원", ["find"] = "페이지 내 검색", ["address"] = "주소창 포커스", ["downloads"] = "다운로드 패널", ["palette"] = "명령 팔레트", ["devtools"] = "개발자 도구", ["fullscreen"] = "전체화면", ["refresh"] = "새로고침", ["print"] = "인쇄", ["pauseall"] = "모두 일시정지", }; ShortcutHelpList.ItemsSource = _shortcuts.All() .Where(b => labels.ContainsKey(b.Command)) .Select(b => new { Gesture = b.Gesture, Label = labels.GetValueOrDefault(b.Command, b.Command) }) .ToList(); } }