Phase B (T1-5/T1-6/T2-2/T2-8/T2-12): download manager flyout w/ speed+pause/resume/cancel, bookmark bar, tab sleeping, tab search (open+closed), context menu w/ 'download video from this link' (79 tests GREEN)

This commit is contained in:
Yun Chan 2026-08-15 15:09:59 +09:00
parent af19dba8f4
commit 0caa1111e7
9 changed files with 763 additions and 3 deletions

View file

@ -10,6 +10,7 @@ using System.Windows.Interop;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.Browser;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Browser.Tabs;
using VideoDownloader.App.Core;
using VideoDownloader.Core.Detection;
@ -38,6 +39,13 @@ public partial class MainWindow : Window
private readonly ObservableCollection<HistoryEntry> _history = new();
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// Phase B: 다운로드 매니저(T1-5) / 탭 절전(T2-2)
private readonly PageDownloadTracker _pageDownloads = new();
private System.Windows.Threading.DispatcherTimer? _speedTimer;
private System.Windows.Threading.DispatcherTimer? _suspendTimer;
private TabSuspendService? _suspendService;
private readonly System.Diagnostics.Stopwatch _speedWatch = new();
// T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면
private SearchEngineResolver _resolver = null!;
private SiteZoomStore _zoomStore = null!;
@ -175,6 +183,10 @@ public partial class MainWindow : Window
if (tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText;
};
_views.ContextMenuBuild += OnContextMenuBuild;
_views.ContextMenuBuild += OnContextMenuBuild;
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
_tabs.ActiveChanged += OnActiveTabChanged;
_tabs.TabRemoved += OnTabRemoved;
@ -188,6 +200,34 @@ public partial class MainWindow : Window
else
_tabs.NewTab(_config.HomeUrl);
UpdateNavState();
// T1-5 다운로드 속도 갱신 타이머
DownloadsList.ItemsSource = _pageDownloads.Items;
_speedTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_speedTimer.Tick += (_, _) =>
{
foreach (var item in _pageDownloads.Items)
item.UpdateSpeed(item.Op.BytesReceived, TimeSpan.FromSeconds(1));
DownloadsEmptyHint.Visibility = _pageDownloads.Items.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
};
_speedTimer.Start();
// T2-2 탭 절전: 1분마다 유휴(10분+) 비활성 탭 서스펜드
_suspendService = new TabSuspendService(TimeSpan.FromMinutes(10), tab =>
{
var cv = tab.Core;
if (cv == null || cv.IsSuspended) return false;
if (tab.View?.Visibility != Visibility.Collapsed) return false; // 숨겨진 탭만 서스펜드 가능
_ = cv.TrySuspendAsync();
return true;
}, tab => tab.Core != null);
_suspendTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
_suspendTimer.Tick += (_, _) =>
{
if (_tabs.ActiveTab != null)
_suspendService.SuspendIdleTabs(_tabs.Tabs, _tabs.ActiveTab, DateTime.UtcNow);
};
_suspendTimer.Start();
}
/// <summary>탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).</summary>
@ -227,15 +267,46 @@ public partial class MainWindow : Window
if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}";
de.ResultFilePath = MakeUniqueDownloadName(folder, fn);
_manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}");
var item = _pageDownloads.Add(new DownloadOperationAdapter(de.DownloadOperation), fn); // T1-5
item.UpdateSpeed(0, TimeSpan.FromSeconds(1));
DownloadsEmptyHint.Visibility = Visibility.Collapsed;
}
catch { }
}
// ---- T1-5 다운로드 플라이아웃 ----
private void ToggleDownloads(object sender, RoutedEventArgs e)
{
DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen;
}
private void DownloadsClear_Click(object sender, RoutedEventArgs e) => _pageDownloads.ClearCompleted();
private void DlPause_Click(object sender, RoutedEventArgs e)
=> IfDownloadItem(sender, i => _pageDownloads.Pause(i));
private void DlResume_Click(object sender, RoutedEventArgs e)
=> IfDownloadItem(sender, i => _pageDownloads.Resume(i));
private void DlCancel_Click(object sender, RoutedEventArgs e)
=> IfDownloadItem(sender, i => _pageDownloads.Cancel(i));
private static void IfDownloadItem(object sender, Action<PageDownloadItem> act)
{
if ((sender as FrameworkElement)?.DataContext is PageDownloadItem item) act(item);
}
private void OnActiveTabChanged(TabItem tab)
{
foreach (var t in _tabs.Tabs)
if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed;
if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url;
if (tab.IsSuspended) // T2-2: 절전 탭 활성화 시 재개
{
try { tab.Core?.Resume(); } catch { }
tab.IsSuspended = false;
}
UpdateNavState();
}
@ -575,6 +646,9 @@ public partial class MainWindow : Window
// Phase A 탭 단축키
if (mods == ModifierKeys.Control && e.Key == Key.T) { _tabs.NewTab(_config.HomeUrl); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.J) { DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen; e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.A) { ToggleTabSearch(this, e); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.B) { ToggleBookmarkBar(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.T) { _tabs.ReopenClosedTab(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.W) { _tabs.CloseTab(_tabs.ActiveTab!); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.W) { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); e.Handled = true; }
@ -819,6 +893,7 @@ public partial class MainWindow : Window
var title = Trunc(string.IsNullOrWhiteSpace(cv!.DocumentTitle) ? url : cv.DocumentTitle, 80);
_bookmarks.Insert(0, new Bookmark(title, url));
BrowserStore.SaveBookmarks(_bookmarks.ToList());
if (BookmarkBar.Visibility == Visibility.Visible) RefreshBookmarkBar();
Log($"즐겨찾기 추가: {title}");
}
@ -873,6 +948,137 @@ public partial class MainWindow : Window
BrowserStore.SaveHistory(_history.ToList());
}
// ---- T2-8 탭 검색 (Ctrl+Shift+A) ----
private void ToggleTabSearch(object sender, RoutedEventArgs e)
{
var open = !TabSearchPopup.IsOpen;
TabSearchPopup.IsOpen = open;
if (open)
{
TabSearchBox.Clear();
TabSearchBox.Focus();
}
}
private void TabSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var results = TabSearchFilter.Search(_tabs.Tabs, _tabs.ClosedStack, TabSearchBox.Text);
TabSearchResults.ItemsSource = results;
TabSearchResults.SelectedIndex = results.Count > 0 ? 0 : -1;
}
private void TabSearchBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) { TabSearchPopup.IsOpen = false; e.Handled = true; }
else if (e.Key == Key.Down && TabSearchResults.HasItems) { TabSearchResults.SelectedIndex = Math.Min(TabSearchResults.SelectedIndex + 1, TabSearchResults.Items.Count - 1); e.Handled = true; }
else if (e.Key == Key.Up && TabSearchResults.HasItems) { TabSearchResults.SelectedIndex = Math.Max(TabSearchResults.SelectedIndex - 1, 0); e.Handled = true; }
else if (e.Key == Key.Enter)
{
if (TabSearchResults.SelectedItem is TabSearchResult sel) ActivateSearchResult(sel);
e.Handled = true;
}
}
private void TabSearchResult_Click(object sender, MouseButtonEventArgs e)
{
if (TabSearchResults.SelectedItem is TabSearchResult sel) ActivateSearchResult(sel);
}
private void ActivateSearchResult(TabSearchResult sel)
{
TabSearchPopup.IsOpen = false;
if (sel.IsClosed)
{
var reopened = _tabs.ReopenClosedTab();
if (reopened == null) _tabs.NewTab(sel.Url);
}
else _tabs.Activate(sel.Tab);
}
// ---- T1-6 북마크바 (Ctrl+Shift+B) ----
private void ToggleBookmarkBar()
{
BookmarkBar.Visibility = BookmarkBar.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible;
if (BookmarkBar.Visibility == Visibility.Visible) RefreshBookmarkBar();
}
private void RefreshBookmarkBar() => BookmarkBarList.ItemsSource = _bookmarks.Take(30).ToList();
private void BookmarkBar_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is Bookmark b) NavigateTo(b.Url);
}
// ---- T2-12 컨텍스트 메뉴 ----
private void OnContextMenuBuild(CoreWebView2 cv, CoreWebView2ContextMenuRequestedEventArgs ce)
{
try
{
var menu = new ContextMenu();
var target = ce.ContextMenuTarget;
var link = target?.LinkUri;
if (!string.IsNullOrEmpty(link))
{
var download = new MenuItem { Header = "이 링크의 영상 받기", FontWeight = FontWeights.Medium };
download.Click += (_, _) => QueueLinkForDownload(link);
menu.Items.Add(download);
var copy = new MenuItem { Header = "링크 주소 복사" };
copy.Click += (_, _) => { try { Clipboard.SetText(link); } catch { } };
menu.Items.Add(copy);
menu.Items.Add(new Separator());
}
foreach (var builtin in ce.MenuItems)
{
if (builtin.Kind == CoreWebView2ContextMenuItemKind.Separator)
{
if (menu.Items.Count > 0 && menu.Items[^1] is not Separator) menu.Items.Add(new Separator());
continue;
}
if (builtin.Kind != CoreWebView2ContextMenuItemKind.Command) continue;
var mi = new MenuItem { Header = builtin.Label, IsEnabled = builtin.IsEnabled };
var commandId = builtin.CommandId;
mi.Click += (_, _) =>
{
try { ce.SelectedCommandId = commandId; ce.Handled = true; } catch { }
};
menu.Items.Add(mi);
}
while (menu.Items.Count > 0 && menu.Items[^1] is Separator) menu.Items.RemoveAt(menu.Items.Count - 1);
var view = ActiveView;
if (view != null)
{
var pt = view.PointToScreen(new Point(ce.Location.X, ce.Location.Y));
menu.PlacementTarget = view;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.AbsolutePoint;
menu.HorizontalOffset = pt.X;
menu.VerticalOffset = pt.Y;
}
menu.IsOpen = true;
}
catch { }
}
private void QueueLinkForDownload(string? link)
{
if (string.IsNullOrEmpty(link)) return;
if (!Uri.TryCreate(link, UriKind.Absolute, out var uri)) return;
var referer = Cv?.Source ?? link;
if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
{
_manager.LogPanel($"컨텍스트 메뉴 소셜 감지 [{label}]: {normalized}");
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
return;
}
var kind = MediaDetector.Classify(link, "", -1, _config) ?? MediaKind.Direct;
_manager.LogPanel($"컨텍스트 메뉴 감지 [{kind}]: {link}");
_manager.OnDetected(new DetectionContext(link, referer, kind, ""));
}
// ---- 카드 액션 ----
private void CardDownload_Click(object sender, RoutedEventArgs e)
{