diff --git a/VideoDownloader.App/Browser/Features/DownloadOperationAdapter.cs b/VideoDownloader.App/Browser/Features/DownloadOperationAdapter.cs
new file mode 100644
index 0000000..3af5135
--- /dev/null
+++ b/VideoDownloader.App/Browser/Features/DownloadOperationAdapter.cs
@@ -0,0 +1,39 @@
+using System.Windows.Data;
+using Microsoft.Web.WebView2.Core;
+
+namespace VideoDownloader.App.Browser.Features;
+
+/// CoreWebView2DownloadOperation → IDownloadOperation 어댑터.
+public sealed class DownloadOperationAdapter : IDownloadOperation
+{
+ private readonly CoreWebView2DownloadOperation _op;
+ public DownloadOperationAdapter(CoreWebView2DownloadOperation op) => _op = op;
+
+ public string Uri => _op.Uri ?? "";
+ public string FileName => Path.GetFileName(_op.ResultFilePath ?? "");
+ public long BytesReceived => (long)_op.BytesReceived;
+ public long TotalBytesToReceive => (long)(_op.TotalBytesToReceive ?? 0);
+ public string State => _op.State.ToString();
+
+ public void Pause() => _op.Pause();
+ public void Resume() => _op.Resume();
+ public void Cancel() => _op.Cancel();
+
+ public event Action? Changed
+ {
+ add { _op.BytesReceivedChanged += (_, _) => value?.Invoke(); _op.StateChanged += (_, _) => value?.Invoke(); }
+ remove { }
+ }
+}
+
+/// 탭 검색 결과의 IsClosed → "닫힘" 라벨 변환.
+public sealed class BoolToClosedConverter : IValueConverter
+{
+ public static readonly BoolToClosedConverter Instance = new();
+
+ public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
+ => value is true ? "닫힘 · 클릭해서 복원" : "";
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/VideoDownloader.App/Browser/Features/PageDownloadTracker.cs b/VideoDownloader.App/Browser/Features/PageDownloadTracker.cs
new file mode 100644
index 0000000..c06deb5
--- /dev/null
+++ b/VideoDownloader.App/Browser/Features/PageDownloadTracker.cs
@@ -0,0 +1,108 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace VideoDownloader.App.Browser.Features;
+
+/// CoreWebView2DownloadOperation 의 최소 추상화(단위 테스트 fake 교체 가능).
+public interface IDownloadOperation
+{
+ string Uri { get; }
+ string FileName { get; }
+ long BytesReceived { get; }
+ long TotalBytesToReceive { get; }
+ /// InProgress / Interrupted / Completed / Cancelled
+ string State { get; }
+ void Pause();
+ void Resume();
+ void Cancel();
+ /// BytesReceived/State 변경 통지(구현은 호스트 스레드에서 발화).
+ event Action? Changed;
+}
+
+/// 다운로드 매니저 UI 항목(T1-5) — 진행률/속도/상태를 INPC 로 노출.
+public sealed class PageDownloadItem : INotifyPropertyChanged
+{
+ public IDownloadOperation Op { get; }
+ public string FileName { get; }
+ public string Uri { get; }
+ public long TotalBytes { get; }
+ private long _received;
+ private long _bps;
+ private string _state;
+
+ public PageDownloadItem(IDownloadOperation op, string fileName)
+ {
+ Op = op;
+ FileName = fileName;
+ Uri = op.Uri;
+ TotalBytes = op.TotalBytesToReceive;
+ _received = op.BytesReceived;
+ _state = op.State;
+ op.Changed += () =>
+ {
+ _received = op.BytesReceived;
+ _state = op.State;
+ OnPropertyChangedAll();
+ };
+ }
+
+ public double Percent => TotalBytes > 0 ? Math.Clamp(_received * 100.0 / TotalBytes, 0, 100) : 0;
+ public long BytesPerSecond => _bps;
+ public string State => _state;
+ public bool IsFinished => _state is "Completed" or "Interrupted" or "Cancelled";
+ public bool CanControl => _state == "InProgress";
+
+ public string SpeedText => _bps switch
+ {
+ <= 0 => "—",
+ < 1024 => $"{(double)_bps:F1} B/s",
+ < 1024 * 1024 => $"{_bps / 1024.0:F1} KB/s",
+ _ => $"{_bps / 1024.0 / 1024.0:F1} MB/s",
+ };
+
+ private long? _lastSpeedReceived;
+
+ /// 속도 갱신: (현재까지 수신 바이트, 직전 호출로부터 경과).
+ public void UpdateSpeed(long receivedNow, TimeSpan elapsed)
+ {
+ _received = receivedNow;
+ if (elapsed.TotalSeconds > 0)
+ {
+ var delta = Math.Max(0, receivedNow - (_lastSpeedReceived ?? 0));
+ _bps = (long)(delta / elapsed.TotalSeconds);
+ }
+ _lastSpeedReceived = receivedNow;
+ OnPropertyChangedAll();
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void OnPropertyChangedAll()
+ {
+ foreach (var n in new[] { nameof(Percent), nameof(BytesPerSecond), nameof(SpeedText), nameof(State), nameof(IsFinished), nameof(CanControl) })
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
+ }
+}
+
+/// 페이지 다운로드 목록(T1-5) — 최신순, 일시정지/재개/취소, 완료 정리.
+public sealed class PageDownloadTracker
+{
+ public ObservableCollection Items { get; } = new();
+
+ public PageDownloadItem Add(IDownloadOperation op, string fileName)
+ {
+ var item = new PageDownloadItem(op, fileName);
+ Items.Insert(0, item);
+ return item;
+ }
+
+ public void Pause(PageDownloadItem item) => item.Op.Pause();
+ public void Resume(PageDownloadItem item) => item.Op.Resume();
+ public void Cancel(PageDownloadItem item) => item.Op.Cancel();
+
+ public void ClearCompleted()
+ {
+ foreach (var done in Items.Where(i => i.IsFinished).ToList())
+ Items.Remove(done);
+ }
+}
diff --git a/VideoDownloader.App/Browser/Features/TabSearchFilter.cs b/VideoDownloader.App/Browser/Features/TabSearchFilter.cs
new file mode 100644
index 0000000..27268dc
--- /dev/null
+++ b/VideoDownloader.App/Browser/Features/TabSearchFilter.cs
@@ -0,0 +1,30 @@
+using VideoDownloader.Browser.Tabs;
+
+namespace VideoDownloader.App.Browser.Features;
+
+/// 탭 검색 결과(T2-8, Chrome Ctrl+Shift+A 패턴).
+public sealed record TabSearchResult(TabItem Tab, string Title, string Url, bool IsClosed);
+
+/// 열린(+닫힌) 탭을 제목/URL 부분일치로 검색. 대소문자 무시.
+public static class TabSearchFilter
+{
+ public static List Search(IEnumerable openTabs, string? query)
+ => Search(openTabs, Array.Empty(), query);
+
+ public static List Search(IEnumerable openTabs, IEnumerable closedTabs, string? query)
+ {
+ query = query?.Trim();
+ if (string.IsNullOrEmpty(query)) return new();
+ var low = query.ToLowerInvariant();
+ var results = new List();
+ foreach (var t in openTabs)
+ if (Matches(t)) results.Add(new TabSearchResult(t, t.Title, t.Url, IsClosed: false));
+ foreach (var t in closedTabs)
+ if (Matches(t)) results.Add(new TabSearchResult(t, t.Title, t.Url, IsClosed: true));
+ return results;
+
+ bool Matches(TabItem t) =>
+ (t.Title ?? "").ToLowerInvariant().Contains(low)
+ || (t.Url ?? "").ToLowerInvariant().Contains(low);
+ }
+}
diff --git a/VideoDownloader.App/Browser/Features/TabSuspendService.cs b/VideoDownloader.App/Browser/Features/TabSuspendService.cs
new file mode 100644
index 0000000..b7d463b
--- /dev/null
+++ b/VideoDownloader.App/Browser/Features/TabSuspendService.cs
@@ -0,0 +1,41 @@
+using VideoDownloader.Browser.Tabs;
+
+namespace VideoDownloader.App.Browser.Features;
+
+///
+/// 탭 절전(T2-2, Edge Sleeping Tabs 패턴): 유휴 비활성 탭을 TrySuspendAsync 로
+/// 서스펜드해 메모리를 회수한다. 실제 서스펜드 수행은 주입(suspendAction) —
+/// WebView2 TrySuspendAsync 는 뷰가 숨겨진(비활성) 탭에서만 동작.
+///
+public sealed class TabSuspendService
+{
+ private readonly TimeSpan _idleTimeout;
+ private readonly Func _suspendAction;
+ private readonly Func _canSuspend;
+
+ public TabSuspendService(TimeSpan idleTimeout, Func suspendAction, Func canSuspend)
+ {
+ _idleTimeout = idleTimeout;
+ _suspendAction = suspendAction;
+ _canSuspend = canSuspend;
+ }
+
+ /// 유휴 비활성 탭 서스펜드. 반환: 서스펜드된 탭 수.
+ public int SuspendIdleTabs(IEnumerable tabs, TabItem active, DateTime now)
+ {
+ var count = 0;
+ foreach (var tab in tabs)
+ {
+ if (ReferenceEquals(tab, active)) continue;
+ if (tab.IsSuspended) continue;
+ if (!_canSuspend(tab)) continue;
+ if (now - tab.LastActivated < _idleTimeout) continue;
+ try
+ {
+ if (_suspendAction(tab)) { tab.IsSuspended = true; count++; }
+ }
+ catch { /* 서스펜드 실패는 건너뜀 */ }
+ }
+ return count;
+ }
+}
diff --git a/VideoDownloader.App/MainWindow.xaml b/VideoDownloader.App/MainWindow.xaml
index d1a1a08..ab8a71b 100644
--- a/VideoDownloader.App/MainWindow.xaml
+++ b/VideoDownloader.App/MainWindow.xaml
@@ -5,6 +5,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
xmlns:vm="clr-namespace:VideoDownloader.App.ViewModels"
+ xmlns:local="clr-namespace:VideoDownloader.App"
+ xmlns:feat="clr-namespace:VideoDownloader.App.Browser.Features"
mc:Ignorable="d"
FontFamily="{StaticResource UiFont}"
Title="Video Downloader" Height="820" Width="1300"
@@ -153,6 +155,7 @@
+
@@ -186,6 +189,8 @@
+
+
@@ -210,8 +215,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-