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 @@ + + + + + + -