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

@ -0,0 +1,39 @@
using System.Windows.Data;
using Microsoft.Web.WebView2.Core;
namespace VideoDownloader.App.Browser.Features;
/// <summary>CoreWebView2DownloadOperation → IDownloadOperation 어댑터.</summary>
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 { }
}
}
/// <summary>탭 검색 결과의 IsClosed → "닫힘" 라벨 변환.</summary>
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();
}

View file

@ -0,0 +1,108 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace VideoDownloader.App.Browser.Features;
/// <summary>CoreWebView2DownloadOperation 의 최소 추상화(단위 테스트 fake 교체 가능).</summary>
public interface IDownloadOperation
{
string Uri { get; }
string FileName { get; }
long BytesReceived { get; }
long TotalBytesToReceive { get; }
/// <summary>InProgress / Interrupted / Completed / Cancelled</summary>
string State { get; }
void Pause();
void Resume();
void Cancel();
/// <summary>BytesReceived/State 변경 통지(구현은 호스트 스레드에서 발화).</summary>
event Action? Changed;
}
/// <summary>다운로드 매니저 UI 항목(T1-5) — 진행률/속도/상태를 INPC 로 노출.</summary>
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;
/// <summary>속도 갱신: (현재까지 수신 바이트, 직전 호출로부터 경과).</summary>
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));
}
}
/// <summary>페이지 다운로드 목록(T1-5) — 최신순, 일시정지/재개/취소, 완료 정리.</summary>
public sealed class PageDownloadTracker
{
public ObservableCollection<PageDownloadItem> 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);
}
}

View file

@ -0,0 +1,30 @@
using VideoDownloader.Browser.Tabs;
namespace VideoDownloader.App.Browser.Features;
/// <summary>탭 검색 결과(T2-8, Chrome Ctrl+Shift+A 패턴).</summary>
public sealed record TabSearchResult(TabItem Tab, string Title, string Url, bool IsClosed);
/// <summary>열린(+닫힌) 탭을 제목/URL 부분일치로 검색. 대소문자 무시.</summary>
public static class TabSearchFilter
{
public static List<TabSearchResult> Search(IEnumerable<TabItem> openTabs, string? query)
=> Search(openTabs, Array.Empty<TabItem>(), query);
public static List<TabSearchResult> Search(IEnumerable<TabItem> openTabs, IEnumerable<TabItem> closedTabs, string? query)
{
query = query?.Trim();
if (string.IsNullOrEmpty(query)) return new();
var low = query.ToLowerInvariant();
var results = new List<TabSearchResult>();
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);
}
}

View file

@ -0,0 +1,41 @@
using VideoDownloader.Browser.Tabs;
namespace VideoDownloader.App.Browser.Features;
/// <summary>
/// 탭 절전(T2-2, Edge Sleeping Tabs 패턴): 유휴 비활성 탭을 TrySuspendAsync 로
/// 서스펜드해 메모리를 회수한다. 실제 서스펜드 수행은 주입(suspendAction) —
/// WebView2 TrySuspendAsync 는 뷰가 숨겨진(비활성) 탭에서만 동작.
/// </summary>
public sealed class TabSuspendService
{
private readonly TimeSpan _idleTimeout;
private readonly Func<TabItem, bool> _suspendAction;
private readonly Func<TabItem, bool> _canSuspend;
public TabSuspendService(TimeSpan idleTimeout, Func<TabItem, bool> suspendAction, Func<TabItem, bool> canSuspend)
{
_idleTimeout = idleTimeout;
_suspendAction = suspendAction;
_canSuspend = canSuspend;
}
/// <summary>유휴 비활성 탭 서스펜드. 반환: 서스펜드된 탭 수.</summary>
public int SuspendIdleTabs(IEnumerable<TabItem> 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;
}
}