releases/VideoDownloader.App/MainWindow.Usecases.cs

1244 lines
53 KiB
C#

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;
/// <summary>
/// MainWindow partial — "UI 복잡 버전"(docs/UI_USECASES.md §A~§J) 유즈케이스 구현.
/// 기존 MainWindow.xaml.cs 의 뼈대(탭·다운로드·팔레트)는 그대로 두고
/// 신규 컨트롤의 이벤트 처리기·공개 API 를 이 파일에 모은다.
/// </summary>
public partial class MainWindow
{
// ---- §E/§J 상태 갱신 타이머·서비스 ----
private System.Windows.Threading.DispatcherTimer? _uiTimer;
private ToastNotifier? _toast;
private ShortcutManager _shortcuts = null!;
private readonly Queue<double> _speedHistory = new();
private List<LibraryEntry> _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;
/// <summary>생성자에서 호출 — 유즈케이스 컨트롤 초기화·이벤트 배선.</summary>
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<string> { "작업" };
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();
}
/// <summary>저장된 환경설정(§C/§D/§E/§J)을 시작 시 실제 UI·동작에 반영한다.</summary>
private void ApplyStartupPreferences()
{
// UC-L16 툴바 버튼 노출 선택(빈 목록 = 전부 표시)
if (_config.ToolbarButtons is { Count: > 0 })
{
var map = new Dictionary<string, System.Windows.Controls.Control>
{
["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);
}
/// <summary>UC-L18 탭 스트립 위치: top(기본) / bottom(본문과 행 교환) / left(세로 탭).</summary>
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;
}
}
/// <summary>UC-N09 업데이트 가능 배지 표시(실패해도 조용히).</summary>
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 테마 라이브 전환 ====================
/// <summary>UC-T04: 이 창을 다시 만들지 않고 테마를 즉시 재적용한다.</summary>
public void ApplyTheme(string theme)
{
App.ApplyTheme(theme, _config.CustomThemePath);
App.ApplyAccent(_config.AccentColor); // 테마 사전을 갈아끼우므로 강조색도 다시 반영
UpdateTitleBarTheme();
UpdateServerStatusIcon();
}
/// <summary>UC-T30: DWM 타이틀바 특성(밝기·모서리)을 현재 테마에 맞게 다시 적용.</summary>
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 레이아웃 ====================
/// <summary>UC-L19: 패널 폭 더블클릭으로 기본값(420px) 복원.</summary>
public void ResetPanelWidth()
{
_panelExpandedWidth = 420;
ApplyPanelState(false);
PersistPanel();
}
private void RightSplitter_DoubleClick(object sender, MouseButtonEventArgs e) => ResetPanelWidth();
/// <summary>UC-L25 UI 전체 배율(0.5~2.0) — 레이아웃 루트 변환으로 적용.</summary>
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 탭 ====================
/// <summary>UC-B10 휠로 탭 전환(설정 켜져 있을 때).</summary>
private void TabStrip_MouseWheel(object sender, MouseWheelEventArgs e)
{
if (!_config.TabWheelSwitch) return;
if (e.Delta > 0) _tabs.ActivatePrevious();
else _tabs.ActivateNext();
e.Handled = true;
}
/// <summary>UC-B05 탭 호버 미리보기 — ToolTip 에 제목+상태+URL.</summary>
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); // 항목 폭+여백 근사
}
/// <summary>x 좌표로 삽입 인덱스 계산(탭 폭 근사 138px).</summary>
private int IndexAtX(double x) => Math.Clamp((int)(x / 138), 0, Math.Max(0, _tabs.Tabs.Count));
/// <summary>탭 번호 배지(UC-B07)·잠금 아이콘 갱신 — 탭 목록 변화 시 코드에서 호출.</summary>
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;
}
/// <summary>UC-B21 새 탭 보조 메뉴 — 최근 페이지·즐겨찾기로 바로 열기.</summary>
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;
}
/// <summary>UC-B24 그룹 전체를 즐겨찾기로 저장.</summary>
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("");
}
/// <summary>UC-B18/B20 작업공간 전환.</summary>
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);
}
/// <summary>UC-B23 탭 볼륨 슬라이더 — 페이지 내 미디어 볼륨을 JS 로 제어. 탭 컨텍스트 메뉴에서 호출.</summary>
internal void OpenTabVolume(TabItem tab)
{
_volumeTab = tab;
TabVolumeSlider.Value = 100;
TabVolumeValue.Text = "100%";
TabVolumePopup.IsOpen = true;
}
private void TabVolumeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> 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 다운로드 관리자 ====================
/// <summary>UC-D30 사이트별 묶음 — 카드 목록을 출처 호스트로 그룹핑한 뷰로 교체.</summary>
private void ApplyDownloadGrouping()
{
var view = CollectionViewSource.GetDefaultView(_manager.Items);
if (!view.GroupDescriptions.OfType<PropertyGroupDescription>().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();
}
/// <summary>UC-D01 다운로드 정렬 비교자 — newest(기본 삽입순)/largest/progress/title.</summary>
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();
/// <summary>UC-D06 미디어+페이지 다운로드 전체 일시정지.</summary>
public void PauseAllDownloads()
{
_manager.PauseAll();
_pageDownloads.PauseAll();
Log("모두 일시정지 — 대기·페이지 다운로드가 정지됩니다");
}
/// <summary>UC-D07 전체 재개.</summary>
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();
/// <summary>UC-D23 목록을 CSV 로 내보내기.</summary>
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();
/// <summary>UC-D25 중복 다운로드 확인 — 같은 URL 카드가 이미 있거나 파일이 존재하면 물어본다.</summary>
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--;
}
/// <summary>UC-D27 오류 메시지 복사.</summary>
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 라이브러리 ====================
/// <summary>라이브러리 항목 — 받은 파일 한 건(UC-M01).</summary>
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<LibraryEntry> 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<LibraryEntry>();
}
catch { items = Enumerable.Empty<LibraryEntry>(); }
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();
}
/// <summary>UC-M02 그리드/리스트 보기 전환.</summary>
private void LibraryViewToggle_Click(object sender, RoutedEventArgs e)
{
_libraryGrid = !_libraryGrid;
LibraryViewToggle.Content = _libraryGrid ? "\uE8FD; 그리드" : "\uE71B; 리스트";
RefreshLibrary();
}
/// <summary>UC-M07 더블클릭 재생.</summary>
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<MediaTrack> 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));
}
/// <summary>UC-M11 셔플 재생.</summary>
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();
}
/// <summary>UC-M12 반복 모드 순환(끔 → 전체 → 한곡).</summary>
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}"); }
}
/// <summary>UC-M14 순서 변경.</summary>
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));
}
/// <summary>UC-K16 즐겨찾기 관리 창.</summary>
private void BookmarkManager_Click(object sender, RoutedEventArgs e)
{
BookmarksPopup.IsOpen = false;
new BookmarkManagerWindow(_bookmarks, () => { BrowserStore.SaveBookmarks(_bookmarks.ToList()); RefreshBookmarkBar(); })
{ Owner = this }.ShowDialog();
}
/// <summary>UC-M19 브라우저 호환 HTML 즐겨찾기 내보내기.</summary>
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("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
sb.AppendLine("<TITLE>Bookmarks</TITLE><H1>Bookmarks</H1><DL><p>");
foreach (var b in _bookmarks)
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());
_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;
}
/// <summary>UC-M20 방문 기록 날짜별 묶음 뷰 — 같은 날 첫 항목에만 헤더 표시.</summary>
private List<HistoryRow> GroupHistoryByDate(IEnumerable<HistoryEntry> history)
{
var rows = new List<HistoryRow>();
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;
}
/// <summary>기록 행 뷰(날짜 묶음 헤더 표시용).</summary>
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));
}
/// <summary>UC-K17 이 항목만 기록에서 삭제.</summary>
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 인터랙션 ====================
/// <summary>UC-K10 Esc 로 열린 모든 팝업 일괄 닫기.</summary>
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;
}
/// <summary>UC-K12 F12 개발자 도구.</summary>
private void OpenDevTools()
{
try { Cv?.OpenDevToolsWindow(); } catch { Log("개발자 도구를 열 수 없습니다"); }
}
/// <summary>UC-K13 프로필(세션) 전환 — 별도 WebView2 프로필 폴더로 재시작.</summary>
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();
}
/// <summary>프로필 전환용 폴더(ProfileFolder 확장).</summary>
internal static string ProfileFolderFor(string? profile) =>
profile == "2" ? Path.Combine(AppDataDir, "webview2-profile2") : ProfileFolder;
/// <summary>UC-K18 화면 영역 지정 캡처 — 전체 화면 캡처 후 사용자가 드래그한 영역을 잘라 저장.</summary>
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<Rect?>();
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}"); }
}
/// <summary>UC-K07 주소창 컨텍스트 "붙여넣고 이동".</summary>
private void PasteAndGo_Click(object sender, RoutedEventArgs e)
{
try
{
var text = Clipboard.GetText().Trim();
if (text.Length > 0) NavigateTo(text);
}
catch { }
}
/// <summary>UC-K05 마우스 제스처 — XButton1(측면 뒤로)/XButton2(앞으로) 로 탐색.</summary>
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;
}
}
/// <summary>UC-K06 휠 클릭(가운데 버튼) 링크 → 새 탭. 페이지에 스크립트를 심어
/// auxclick(middle) 이벤트를 window.chrome.webmessage 로 전달받는다.</summary>
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; }
}
/// <summary>UC-K08 주소창 포커스 시 전체 선택(설정 켜져 있을 때).</summary>
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 상태·알림 ====================
/// <summary>상태바 집계 갱신 — 통합 진행률·요약·전체 속도·스파크라인·배지.</summary>
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 { }
}
/// <summary>완료/오류를 토스트로 알림(DownloadManager 이벤트에서 호출).</summary>
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;
/// <summary>치명 오류 배너 표시(스택 포함).</summary>
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;
/// <summary>UC-N06 클립보드에 미디어/소셜 URL 이 있으면 받기 제안 배지.</summary>
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();
/// <summary>서버 상태 아이콘 갱신(원격 접근 토글 후 호출).</summary>
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<string>();
NotificationCenterPopup.IsOpen = true;
}
private void ShortcutHelpBtn_Click(object sender, RoutedEventArgs e) => ShortcutHelpPopup.IsOpen = true;
/// <summary>단축키 도움말 목록 갱신(사용자 재배치 반영).</summary>
private void UpdateShortcutHelp()
{
var labels = new Dictionary<string, string>
{
["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();
}
}