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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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="" ToolTip="즐겨찾기" Margin="0,0,2,0" Click="ToggleBookmarks"/>
|
<Button Name="BookmarksBtn" Style="{StaticResource IconButton}" Content="" ToolTip="즐겨찾기" Margin="0,0,2,0" Click="ToggleBookmarks"/>
|
||||||
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
|
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
|
||||||
|
<Button Name="TabSearchBtn" Style="{StaticResource IconButton}" Content="" ToolTip="탭 검색 (Ctrl+Shift+A)" Margin="0,0,2,0" Click="ToggleTabSearch"/>
|
||||||
|
<Button Name="DownloadsBtn" Style="{StaticResource IconButton}" Content="" ToolTip="다운로드 (Ctrl+J)" Margin="0,0,2,0" Click="ToggleDownloads"/>
|
||||||
<Button Style="{StaticResource IconButton}" Content="" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
|
<Button Style="{StaticResource IconButton}" Content="" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
|
||||||
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
|
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
|
||||||
<Button x:Name="PrivateBtn" Style="{StaticResource IconButton}" Content="" ToolTip="개인정보 보호 모드(InPrivate)" Margin="0,0,2,0" Click="TogglePrivate_Click"/>
|
<Button x:Name="PrivateBtn" Style="{StaticResource IconButton}" Content="" 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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
103
VideoDownloader.Tests/Browser/PageDownloadTrackerTests.cs
Normal file
103
VideoDownloader.Tests/Browser/PageDownloadTrackerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
VideoDownloader.Tests/Browser/TabFeaturesTests.cs
Normal file
113
VideoDownloader.Tests/Browser/TabFeaturesTests.cs
Normal 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, " "));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue