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;
}
}

View file

@ -5,6 +5,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf" xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
xmlns:vm="clr-namespace:VideoDownloader.App.ViewModels" xmlns:vm="clr-namespace:VideoDownloader.App.ViewModels"
xmlns:local="clr-namespace:VideoDownloader.App"
xmlns:feat="clr-namespace:VideoDownloader.App.Browser.Features"
mc:Ignorable="d" mc:Ignorable="d"
FontFamily="{StaticResource UiFont}" FontFamily="{StaticResource UiFont}"
Title="Video Downloader" Height="820" Width="1300" Title="Video Downloader" Height="820" Width="1300"
@ -153,6 +155,7 @@
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
@ -186,6 +189,8 @@
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0"> <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0">
<Button Name="BookmarksBtn" Style="{StaticResource IconButton}" Content="&#xE734;" ToolTip="즐겨찾기" Margin="0,0,2,0" Click="ToggleBookmarks"/> <Button Name="BookmarksBtn" Style="{StaticResource IconButton}" Content="&#xE734;" ToolTip="즐겨찾기" Margin="0,0,2,0" Click="ToggleBookmarks"/>
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="&#xE823;" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/> <Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="&#xE823;" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
<Button Name="TabSearchBtn" Style="{StaticResource IconButton}" Content="&#xE721;" ToolTip="탭 검색 (Ctrl+Shift+A)" Margin="0,0,2,0" Click="ToggleTabSearch"/>
<Button Name="DownloadsBtn" Style="{StaticResource IconButton}" Content="&#xE896;" ToolTip="다운로드 (Ctrl+J)" Margin="0,0,2,0" Click="ToggleDownloads"/>
<Button Style="{StaticResource IconButton}" Content="&#xE8B7;" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/> <Button Style="{StaticResource IconButton}" Content="&#xE8B7;" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="&#xE71B;" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/> <Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="&#xE71B;" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
<Button x:Name="PrivateBtn" Style="{StaticResource IconButton}" Content="&#xE72E;" ToolTip="개인정보 보호 모드(InPrivate)" Margin="0,0,2,0" Click="TogglePrivate_Click"/> <Button x:Name="PrivateBtn" Style="{StaticResource IconButton}" Content="&#xE72E;" ToolTip="개인정보 보호 모드(InPrivate)" Margin="0,0,2,0" Click="TogglePrivate_Click"/>
@ -210,8 +215,32 @@
</DockPanel> </DockPanel>
</Border> </Border>
<!-- ===== 북마크바 (T1-6, Ctrl+Shift+B 토글) ===== -->
<Border x:Name="BookmarkBar" Grid.Row="1" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1" Padding="8,3"
Visibility="Collapsed">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl x:Name="BookmarkBarList" ItemsSource="{Binding}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource FlatButton}" Click="BookmarkBar_Click"
Padding="10,4" Margin="2,1" ToolTip="{Binding Url}">
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis"
MaxWidth="130" FontSize="12"/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- ===== 탭 스트립 (Phase A) ===== --> <!-- ===== 탭 스트립 (Phase A) ===== -->
<Border x:Name="TabStripRow" Grid.Row="1" Background="{StaticResource PanelBg}" <Border x:Name="TabStripRow" Grid.Row="2" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1" Padding="8,4,8,0"> BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1" Padding="8,4,8,0">
<DockPanel> <DockPanel>
<Button x:Name="NewTabBtn" DockPanel.Dock="Right" Style="{StaticResource IconButton}" <Button x:Name="NewTabBtn" DockPanel.Dock="Right" Style="{StaticResource IconButton}"
@ -271,7 +300,7 @@
</Border> </Border>
<!-- ===== 본문 ===== --> <!-- ===== 본문 ===== -->
<Grid Grid.Row="2"> <Grid Grid.Row="3">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
@ -381,7 +410,7 @@
</Grid> </Grid>
<!-- ===== 상태 표시줄 ===== --> <!-- ===== 상태 표시줄 ===== -->
<Border x:Name="StatusBar" Grid.Row="3" Background="{StaticResource PanelBg}" <Border x:Name="StatusBar" Grid.Row="4" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5"> BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11" <TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11"
TextTrimming="CharacterEllipsis" Text="준비"/> TextTrimming="CharacterEllipsis" Text="준비"/>
@ -473,6 +502,87 @@
</Border> </Border>
</Popup> </Popup>
<!-- ===== 다운로드 플라이아웃 (T1-5) ===== -->
<Popup Name="DownloadsPopup" PlacementTarget="{Binding ElementName=DownloadsBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="10" Width="420" MaxHeight="460">
<StackPanel>
<DockPanel Margin="6,2,6,8">
<TextBlock Text="다운로드" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="완료 항목 지우기"
Click="DownloadsClear_Click" HorizontalAlignment="Right"/>
</DockPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="380">
<ItemsControl Name="DownloadsList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,0,8">
<StackPanel>
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding SpeedText}" Foreground="{StaticResource TextMuted}"
FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Text="{Binding FileName}" Foreground="{StaticResource TextPrimary}"
FontSize="12.5" FontWeight="Medium" TextTrimming="CharacterEllipsis"/>
</DockPanel>
<ProgressBar Value="{Binding Percent}" Maximum="100" Height="6" Margin="0,7,0,6"
Style="{StaticResource AccentProgressBar}"/>
<DockPanel>
<TextBlock Text="{Binding State}" Foreground="{StaticResource TextSecondary}" FontSize="11" VerticalAlignment="Center"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource CardActionButton}" Content="일시정지" Click="DlPause_Click" Margin="0,0,5,0"/>
<Button Style="{StaticResource CardActionButton}" Content="재개" Click="DlResume_Click" Margin="0,0,5,0"/>
<Button Style="{StaticResource CardActionButton}" Content="취소" Click="DlCancel_Click"/>
</StackPanel>
</DockPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Text="페이지 내 파일 다운로드가 여기 표시됩니다" Foreground="{StaticResource TextMuted}"
FontSize="11" HorizontalAlignment="Center" Margin="0,4,0,0"
x:Name="DownloadsEmptyHint"/>
</StackPanel>
</Border>
</Popup>
<!-- ===== 탭 검색 팝업 (T2-8, Ctrl+Shift+A) ===== -->
<Popup Name="TabSearchPopup" PlacementTarget="{Binding ElementName=AddressBox}"
Placement="Center" StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade"
Width="520">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="10">
<StackPanel>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Margin="2,0,2,8">
<TextBox Name="TabSearchBox" Style="{StaticResource DarkTextBox}" BorderThickness="0"
Background="Transparent" Padding="12,9" FontSize="13"
TextChanged="TabSearchBox_TextChanged" PreviewKeyDown="TabSearchBox_PreviewKeyDown"/>
</Border>
<ListBox Name="TabSearchResults" Style="{StaticResource SuggestList}"
ItemContainerStyle="{StaticResource SuggestItem}" MaxHeight="320"
PreviewMouseLeftButtonUp="TabSearchResult_Click">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding IsClosed, Converter={x:Static feat:BoolToClosedConverter.Instance}}"
Foreground="{StaticResource TextMuted}" FontSize="10.5" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontSize="12.5" FontWeight="Medium"/>
</DockPanel>
<TextBlock Text="{Binding Url}" Foreground="{StaticResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
</Popup>
<!-- ===== 링크로 받기 플라이아웃 (YouTube/Instagram/X URL 붙여넣기) ===== --> <!-- ===== 링크로 받기 플라이아웃 (YouTube/Instagram/X URL 붙여넣기) ===== -->
<Popup Name="LinkPopup" PlacementTarget="{Binding ElementName=LinkBtn}" <Popup Name="LinkPopup" PlacementTarget="{Binding ElementName=LinkBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6" Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"

View file

@ -10,6 +10,7 @@ using System.Windows.Interop;
using System.Windows.Media; using System.Windows.Media;
using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Core;
using VideoDownloader.Browser; using VideoDownloader.Browser;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Browser.Tabs; using VideoDownloader.Browser.Tabs;
using VideoDownloader.App.Core; using VideoDownloader.App.Core;
using VideoDownloader.Core.Detection; using VideoDownloader.Core.Detection;
@ -38,6 +39,13 @@ public partial class MainWindow : Window
private readonly ObservableCollection<HistoryEntry> _history = new(); private readonly ObservableCollection<HistoryEntry> _history = new();
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지) 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 전체화면 // T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면
private SearchEngineResolver _resolver = null!; private SearchEngineResolver _resolver = null!;
private SiteZoomStore _zoomStore = null!; private SiteZoomStore _zoomStore = null!;
@ -175,6 +183,10 @@ public partial class MainWindow : Window
if (tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText; if (tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText;
}; };
_views.ContextMenuBuild += OnContextMenuBuild;
_views.ContextMenuBuild += OnContextMenuBuild;
_tabs = new TabManager(() => new TabItem(), _views.AttachView); _tabs = new TabManager(() => new TabItem(), _views.AttachView);
_tabs.ActiveChanged += OnActiveTabChanged; _tabs.ActiveChanged += OnActiveTabChanged;
_tabs.TabRemoved += OnTabRemoved; _tabs.TabRemoved += OnTabRemoved;
@ -188,6 +200,34 @@ public partial class MainWindow : Window
else else
_tabs.NewTab(_config.HomeUrl); _tabs.NewTab(_config.HomeUrl);
UpdateNavState(); 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> /// <summary>탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).</summary>
@ -227,15 +267,46 @@ public partial class MainWindow : Window
if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}"; if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}";
de.ResultFilePath = MakeUniqueDownloadName(folder, fn); de.ResultFilePath = MakeUniqueDownloadName(folder, fn);
_manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {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 { } 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) private void OnActiveTabChanged(TabItem tab)
{ {
foreach (var t in _tabs.Tabs) foreach (var t in _tabs.Tabs)
if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed; if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed;
if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url; if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url;
if (tab.IsSuspended) // T2-2: 절전 탭 활성화 시 재개
{
try { tab.Core?.Resume(); } catch { }
tab.IsSuspended = false;
}
UpdateNavState(); UpdateNavState();
} }
@ -575,6 +646,9 @@ public partial class MainWindow : Window
// Phase A 탭 단축키 // Phase A 탭 단축키
if (mods == ModifierKeys.Control && e.Key == Key.T) { _tabs.NewTab(_config.HomeUrl); e.Handled = true; } 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.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 | 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; } 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); var title = Trunc(string.IsNullOrWhiteSpace(cv!.DocumentTitle) ? url : cv.DocumentTitle, 80);
_bookmarks.Insert(0, new Bookmark(title, url)); _bookmarks.Insert(0, new Bookmark(title, url));
BrowserStore.SaveBookmarks(_bookmarks.ToList()); BrowserStore.SaveBookmarks(_bookmarks.ToList());
if (BookmarkBar.Visibility == Visibility.Visible) RefreshBookmarkBar();
Log($"즐겨찾기 추가: {title}"); Log($"즐겨찾기 추가: {title}");
} }
@ -873,6 +948,137 @@ public partial class MainWindow : Window
BrowserStore.SaveHistory(_history.ToList()); 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) private void CardDownload_Click(object sender, RoutedEventArgs e)
{ {

View file

@ -43,6 +43,9 @@ public sealed class TabViewService
/// <summary>탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용).</summary> /// <summary>탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용).</summary>
public event Action? HistoryChanged; public event Action? HistoryChanged;
/// <summary>우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true).</summary>
public event Action<CoreWebView2, CoreWebView2ContextMenuRequestedEventArgs>? ContextMenuBuild;
public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host, public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host,
SiteZoomStore zoomStore, Dispatcher dispatcher) SiteZoomStore zoomStore, Dispatcher dispatcher)
{ {
@ -170,6 +173,13 @@ public sealed class TabViewService
// 페이지 내 다운로드 → 호스트 훅 // 페이지 내 다운로드 → 호스트 훅
cv.DownloadStarting += (_, de) => DownloadStarting?.Invoke(cv, de); cv.DownloadStarting += (_, de) => DownloadStarting?.Invoke(cv, de);
// 우클릭 메뉴 → 호스트가 커스텀 메뉴 구성(기본 메뉴 대체, T2-12/T3-5)
cv.ContextMenuRequested += (_, ce) =>
{
ce.Handled = true;
ContextMenuBuild?.Invoke(cv, ce);
};
// 초기 URL (세션 복원) // 초기 URL (세션 복원)
if (!string.IsNullOrEmpty(tab.Url) && string.IsNullOrEmpty(cv.Source)) if (!string.IsNullOrEmpty(tab.Url) && string.IsNullOrEmpty(cv.Source))
{ {

View file

@ -0,0 +1,103 @@
using VideoDownloader.App.Browser.Features;
namespace VideoDownloader.Tests.Browser;
/// <summary>페이지 다운로드 추상화(다운로드 매니저 UI 의 로직 코어) — fake op 로 검증.</summary>
public interface IFakeOpHost { }
public class PageDownloadTrackerTests
{
private sealed class FakeOp : IDownloadOperation
{
public string Uri { get; set; } = "https://x.test/file.zip";
public string FileName { get; set; } = "file.zip";
public long BytesReceived { get; set; }
public long TotalBytesToReceive { get; set; } = 1000;
public string State { get; set; } = "InProgress";
public bool Paused, Resumed, Cancelled;
public event Action? Changed;
public void Raise() => Changed?.Invoke();
public void Pause() { Paused = true; State = "Paused"; Raise(); }
public void Resume() { Resumed = true; State = "InProgress"; Raise(); }
public void Cancel() { Cancelled = true; State = "Cancelled"; Raise(); }
}
[Fact]
public void Add는_항목을_등록하고_최근순_정렬한다()
{
var t = new PageDownloadTracker();
var a = new FakeOp { FileName = "a.zip" };
var b = new FakeOp { FileName = "b.zip" };
t.Add(a, a.FileName);
t.Add(b, b.FileName);
Assert.Equal(2, t.Items.Count);
Assert.Equal("b.zip", t.Items[0].FileName); // 최신 우선
}
[Fact]
public void 진행률과_속도를_계산한다()
{
var t = new PageDownloadTracker();
var op = new FakeOp { TotalBytesToReceive = 1000 };
var item = t.Add(op, "f.zip");
item.UpdateSpeed(500, TimeSpan.FromSeconds(2)); // 500B/2s = 250 B/s
Assert.Equal(50.0, item.Percent, 2);
Assert.Equal(250, item.BytesPerSecond);
Assert.Equal("250.0 B/s", item.SpeedText);
}
[Fact]
public void 속도_단위는_KB_MB로_표시된다()
{
var t = new PageDownloadTracker();
var item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "f");
item.UpdateSpeed(0, TimeSpan.FromSeconds(1)); // 초기
Assert.Equal("—", item.SpeedText);
item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "g");
item.UpdateSpeed(2048 * 100, TimeSpan.FromSeconds(1)); // 200 KB/s
Assert.Equal("200.0 KB/s", item.SpeedText);
item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "h");
item.UpdateSpeed(3 * 1024 * 1024L, TimeSpan.FromSeconds(1)); // 3 MB/s
Assert.Equal("3.0 MB/s", item.SpeedText);
}
[Fact]
public void Pause_Resume_Cancel_위임()
{
var t = new PageDownloadTracker();
var op = new FakeOp();
var item = t.Add(op, "f");
t.Pause(item); t.Resume(item); t.Cancel(item);
Assert.True(op.Paused && op.Resumed && op.Cancelled);
}
[Fact]
public void 완료_항목은_ClearCompleted로_일괄_제거()
{
var t = new PageDownloadTracker();
var done = new FakeOp { State = "Completed" };
var running = new FakeOp { State = "InProgress" };
t.Add(done, "d"); t.Add(running, "r");
t.ClearCompleted();
Assert.Single(t.Items);
Assert.Equal("r", t.Items[0].FileName);
}
[Fact]
public void 취소된_항목도_ClearCompleted로_제거된다()
{
var t = new PageDownloadTracker();
var c = new FakeOp { State = "Interrupted" };
t.Add(c, "c");
t.ClearCompleted();
Assert.Empty(t.Items);
}
}

View file

@ -0,0 +1,113 @@
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Browser.Tabs;
namespace VideoDownloader.Tests.Browser;
// ── T2-2 탭 절전: 유휴 비활성 탭 자동 서스펜드 ──
public class TabSuspendServiceTests
{
[Fact]
public void 활성_탭은_절전하지_않는다()
{
var m = new TabManager(() => new TabItem());
var active = m.NewTab("https://a.com");
var svc = new TabSuspendService(TimeSpan.FromMinutes(10),
t => throw new InvalidOperationException("suspend should not be called"),
t => t == active);
svc.SuspendIdleTabs(m.Tabs, active, now: active.LastActivated.AddMinutes(30));
Assert.False(active.IsSuspended);
}
[Fact]
public void 유휴_비활성_탭은_절전한다()
{
var m = new TabManager(() => new TabItem());
var old = m.NewTab("https://a.com");
var active = m.NewTab("https://b.com");
var suspended = new List<TabItem>();
var svc = new TabSuspendService(TimeSpan.FromMinutes(10),
t => { suspended.Add(t); return true; },
t => t != active);
var now = old.LastActivated.AddMinutes(11);
svc.SuspendIdleTabs(m.Tabs, active, now);
Assert.Contains(old, suspended);
Assert.DoesNotContain(active, suspended);
}
[Fact]
public void 이미_절전된_탭은_중복_서스펜드_하지_않는다()
{
var m = new TabManager(() => new TabItem());
var old = m.NewTab("https://a.com");
var active = m.NewTab("https://b.com");
old.IsSuspended = true;
var calls = 0;
var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active);
svc.SuspendIdleTabs(m.Tabs, active, old.LastActivated.AddHours(1));
Assert.Equal(0, calls);
}
[Fact]
public void 최근에_활성화된_탭은_절전하지_않는다()
{
var m = new TabManager(() => new TabItem());
var recent = m.NewTab("https://a.com");
var active = m.NewTab("https://b.com");
m.Activate(recent); // 방금 사용
var calls = 0;
var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active);
svc.SuspendIdleTabs(m.Tabs, active, recent.LastActivated.AddMinutes(1));
Assert.Equal(0, calls);
}
}
// ── T2-8 탭 검색(Ctrl+Shift+A): 열린+닫힌 탭 필터 ──
public class TabSearchFilterTests
{
[Fact]
public void 제목과_URL_부분일치로_필터링한다()
{
var m = new TabManager(() => new TabItem());
m.NewTab("https://youtube.com/watch?v=1");
m.Tabs[0].Title = "My Favorite Video";
m.NewTab("https://news.example.com");
m.Tabs[1].Title = "News";
var hits = TabSearchFilter.Search(m.Tabs, "favorite");
Assert.Single(hits);
Assert.Equal("My Favorite Video", hits[0].Title);
hits = TabSearchFilter.Search(m.Tabs, "youtube");
Assert.Single(hits);
hits = TabSearchFilter.Search(m.Tabs, "EXAMPLE"); // 대소문자 무시
Assert.Single(hits);
}
[Fact]
public void 닫힌_탭도_검색_대상에_포함된다()
{
var m = new TabManager(() => new TabItem());
var t = m.NewTab("https://github.com/x");
m.Tabs[0].Title = "GitHub Repo";
m.CloseTab(t);
var hits = TabSearchFilter.Search(m.Tabs, m.ClosedStack, "github");
Assert.Single(hits);
Assert.Equal("GitHub Repo", hits[0].Title);
Assert.True(hits[0].IsClosed);
}
[Fact]
public void 빈_질의는_빈_결과()
{
var m = new TabManager(() => new TabItem());
m.NewTab("https://a.com");
Assert.Empty(TabSearchFilter.Search(m.Tabs, ""));
Assert.Empty(TabSearchFilter.Search(m.Tabs, " "));
}
}