1244 lines
53 KiB
C#
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();
|
|
}
|
|
|
|
|
|
}
|