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:
parent
af19dba8f4
commit
0caa1111e7
9 changed files with 763 additions and 3 deletions
|
|
@ -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();
|
||||
}
|
||||
108
VideoDownloader.App/Browser/Features/PageDownloadTracker.cs
Normal file
108
VideoDownloader.App/Browser/Features/PageDownloadTracker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
30
VideoDownloader.App/Browser/Features/TabSearchFilter.cs
Normal file
30
VideoDownloader.App/Browser/Features/TabSearchFilter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
VideoDownloader.App/Browser/Features/TabSuspendService.cs
Normal file
41
VideoDownloader.App/Browser/Features/TabSuspendService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue