diff --git a/VideoDownloader.App/MainWindow.xaml.cs b/VideoDownloader.App/MainWindow.xaml.cs index 8362bb6..c0d58f1 100644 --- a/VideoDownloader.App/MainWindow.xaml.cs +++ b/VideoDownloader.App/MainWindow.xaml.cs @@ -30,7 +30,6 @@ public partial class MainWindow : Window // ---- 탭 아키텍처 (Phase A) ---- private CoreWebView2Environment? _env; private TabManager _tabs = null!; - private readonly Dictionary _tabInit = new(); internal TabManager Tabs => _tabs; internal Wv2.WebView2? ActiveView => _tabs?.ActiveTab?.View; private CoreWebView2? Cv => _tabs?.ActiveTab?.Core; @@ -162,7 +161,21 @@ public partial class MainWindow : Window } catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; } - _tabs = new TabManager(CreateTabItem, AttachTabView); + _views = new Services.TabViewService(_env!, _config, TabHost, _zoomStore, Dispatcher) + { + NewTabProvider = () => Task.FromResult(_tabs.NewTab(activate: false)), + }; + _views.TabCoreReady += (_, cv) => cv.WebResourceResponseReceived += OnResponseReceived; // 미디어 감지 + _views.DownloadStarting += OnDownloadStarting; + _views.UrlChanged += OnUrlChanged; + _views.TitleChanged += OnTitleChanged; + _views.HistoryChanged += UpdateNavState; + _views.FindStateChanged += tab => + { + if (tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText; + }; + + _tabs = new TabManager(() => new TabItem(), _views.AttachView); _tabs.ActiveChanged += OnActiveTabChanged; _tabs.TabRemoved += OnTabRemoved; TabList.ItemsSource = _tabs.Tabs; @@ -177,147 +190,45 @@ public partial class MainWindow : Window UpdateNavState(); } - private TabItem CreateTabItem() => new(); + /// 탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④). + private Services.TabViewService? _views; - /// 탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드) - private void AttachTabView(TabItem tab) + private void OnUrlChanged(TabItem tab, string url) { - var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black }; - tab.View = view; - TabHost.Children.Add(view); - view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed; - _tabInit[tab] = InitTabCoreAsync(tab); + if (tab.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl); + if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url; + tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url; + DetectSocial(tab, url); } - /// 탭의 CoreWebView2 초기화 + 탭별 이벤트 배선 + 초기 URL 탐색. - private async Task InitTabCoreAsync(TabItem tab) + private void OnTitleChanged(TabItem tab, string title) { - try + tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title; + if (tab.PendingHistoryUrl != null) { - var options = _env!.CreateCoreWebView2ControllerOptions(); - if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함 - await tab.View!.EnsureCoreWebView2Async(_env, options); + RecordHistory(string.IsNullOrWhiteSpace(title) ? tab.PendingHistoryUrl : title, tab.PendingHistoryUrl); + tab.PendingHistoryUrl = null; } - catch (Exception ex) + // 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때) + if (tab.PendingSocialUrl != null && !string.IsNullOrWhiteSpace(title)) { - Log($"탭 초기화 실패: {ex.Message}"); - return; - } - - var cv = tab.Core!; - cv.Settings.AreDevToolsEnabled = true; - - // T1-3 탭별 검색 세션 (네이티브 Find API) - tab.FindSession = new FindSession(new WebView2FindApi(cv, _env!)); - tab.FindSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() => - { - if (tab.IsActive && tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText; - })); - - // 탭별 네비게이션 상태 - cv.NavigationStarting += (_, _) => Dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true)); - cv.NavigationCompleted += (_, _) => Dispatcher.BeginInvoke(new Action(() => - { - tab.IsLoading = false; - try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 - })); - cv.HistoryChanged += (_, _) => UpdateNavState(); - - // 소셜/주소창/기록: 탭별 상태로 관리 - cv.SourceChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() => - { - var url = cv.Source ?? ""; - tab.Url = url; - if (tab.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl); - if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url; - tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url; - DetectSocial(tab, url); - })); - cv.DocumentTitleChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() => - { - var title = cv.DocumentTitle ?? ""; - tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title; - if (tab.PendingHistoryUrl != null) - { - RecordHistory(string.IsNullOrWhiteSpace(title) ? tab.PendingHistoryUrl : title, tab.PendingHistoryUrl); - tab.PendingHistoryUrl = null; - } - // 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때) - if (tab.PendingSocialUrl != null && !string.IsNullOrWhiteSpace(title)) - { - _manager.RenameSocial(new Uri(tab.PendingSocialUrl), title); - tab.PendingSocialUrl = null; - } - })); - - // T1-8 오디오 표시/뮤트 - try - { - cv.IsDocumentPlayingAudioChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() => - { - try { tab.IsAudioPlaying = cv.IsDocumentPlayingAudio; } catch { } - })); - cv.IsMutedChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() => - { - try { tab.IsMuted = cv.IsMuted; } catch { } - })); - } - catch { } // 구현체/타이밍 이슈 대비 - - // 미디어 감지(탭 무관 전역) - cv.WebResourceResponseReceived += OnResponseReceived; - - // Phase A: 팝업/새창 → 새 탭 (deferral 필수 — 탭 생성이 비동기) - cv.NewWindowRequested += async (_, ne) => - { - try - { - var deferral = ne.GetDeferral(); - try - { - var newTab = _tabs.NewTab(activate: false); // URL 없이 생성 → 팝업 내용이 새 창으로 흘러들어옴 - await WaitTabReady(newTab); - ne.NewWindow = newTab.Core; - ne.Handled = true; - } - finally { deferral.Complete(); } - } - catch - { - try { ne.Handled = true; if (ne.Uri != null) cv.Navigate(ne.Uri); } catch { } - } - }; - - // #13 페이지 내 다운로드(a[download])를 저장 폴더로 라우팅 - cv.DownloadStarting += (_, de) => - { - try - { - var folder = Path.GetFullPath(_config.OutputFolder); - Directory.CreateDirectory(folder); - var fn = Path.GetFileName(de.ResultFilePath); - if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}"; - de.ResultFilePath = MakeUniqueDownloadName(folder, fn); - _manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}"); - } - catch { } - }; - - // 초기 URL (세션 복원/빈 탭 제외) - if (!string.IsNullOrEmpty(tab.Url) && (cv.Source == null || cv.Source.Length == 0)) - { - try { cv.Navigate(tab.Url); } catch { } + _manager.RenameSocial(new Uri(tab.PendingSocialUrl), title); + tab.PendingSocialUrl = null; } } - /// 탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용). - private async Task WaitTabReady(TabItem tab) + private void OnDownloadStarting(CoreWebView2 cv, CoreWebView2DownloadStartingEventArgs de) { - if (tab.Core != null) return; - if (_tabInit.TryGetValue(tab, out var t)) { try { await t; } catch { } return; } - var sw = System.Diagnostics.Stopwatch.StartNew(); - while (tab.Core == null && sw.ElapsedMilliseconds < 15000) - await Task.Delay(50); + try + { + var folder = Path.GetFullPath(_config.OutputFolder); + Directory.CreateDirectory(folder); + var fn = Path.GetFileName(de.ResultFilePath); + if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}"; + de.ResultFilePath = MakeUniqueDownloadName(folder, fn); + _manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}"); + } + catch { } } private void OnActiveTabChanged(TabItem tab) @@ -328,15 +239,7 @@ public partial class MainWindow : Window UpdateNavState(); } - private void OnTabRemoved(TabItem tab) - { - if (tab.View != null) - { - TabHost.Children.Remove(tab.View); - tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachTabView) - } - _tabInit.Remove(tab); - } + private void OnTabRemoved(TabItem tab) => _views?.RemoveView(tab); private void UpdateNavState() { diff --git a/VideoDownloader.App/Services/TabViewService.cs b/VideoDownloader.App/Services/TabViewService.cs new file mode 100644 index 0000000..75f3aef --- /dev/null +++ b/VideoDownloader.App/Services/TabViewService.cs @@ -0,0 +1,184 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using Microsoft.Web.WebView2.Core; +using VideoDownloader.Browser; +using VideoDownloader.Browser.Tabs; +using VideoDownloader.Core.Config; +using Wv2 = Microsoft.Web.WebView2.Wpf; +using TabItem = VideoDownloader.Browser.Tabs.TabItem; + +namespace VideoDownloader.App.Services; + +/// +/// 탭별 WebView2 생명주기·이벤트 배선 전담(재구성 ④ — MainWindow 갓-클래스 해체). +/// 일반 브라우저 동작(제목/URL/로딩/오디오/검색세션/줌 적용/초기 탐색/뉴윈도우→탭)은 +/// 이 서비스가, 앱 고유 기능(미디어 감지/다운로드 라우팅/소셜/기록/주소창 반영)은 +/// 이벤트 훅으로 호스트(MainWindow)가 주입한다. +/// +public sealed class TabViewService +{ + private readonly CoreWebView2Environment _env; + private readonly AppConfig _config; + private readonly Panel _host; + private readonly SiteZoomStore _zoomStore; + private readonly Dispatcher _dispatcher; + private readonly Dictionary _init = new(); + + /// 뉴윈도우(팝업) 요청 시 새 탭을 만들어 반환하는 공급자(호스트가 TabManager 에 연결). + public Func>? NewTabProvider { get; set; } + + /// 탭 CoreWebView2 초기화 완료(호스트가 감지 등 탭별 구독 부착). + public event Action? TabCoreReady; + + /// 페이지 내 a[download] 등 다운로드 시작(호스트가 저장 폴더로 라우팅). + public event Action? DownloadStarting; + + /// URL 확정(SourceChanged). 인자: (탭, url). + public event Action? UrlChanged; + + /// 문서 타이틀 확정(DocumentTitleChanged). 인자: (탭, title). + public event Action? TitleChanged; + + /// 탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용). + public event Action? HistoryChanged; + + public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host, + SiteZoomStore zoomStore, Dispatcher dispatcher) + { + _env = env; + _config = config; + _host = host; + _zoomStore = zoomStore; + _dispatcher = dispatcher; + } + + public TabItem CreateTabItem() => new(); + + /// 탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드) + public void AttachView(TabItem tab) + { + var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black }; + tab.View = view; + _host.Children.Add(view); + view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed; + _init[tab] = InitCoreAsync(tab); + } + + /// 탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용). + public async Task WaitTabReady(TabItem tab) + { + if (tab.Core != null) return; + if (_init.TryGetValue(tab, out var t)) { try { await t; } catch { } return; } + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (tab.Core == null && sw.ElapsedMilliseconds < 15000) + await Task.Delay(50); + } + + /// 탭 제거 시 뷰를 호스트에서 떼고 재오픈 가능 상태로(null) 만든다. + public void RemoveView(TabItem tab) + { + if (tab.View != null) + { + _host.Children.Remove(tab.View); + tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachView) + } + _init.Remove(tab); + } + + private async Task InitCoreAsync(TabItem tab) + { + try + { + var options = _env.CreateCoreWebView2ControllerOptions(); + if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함 + await tab.View!.EnsureCoreWebView2Async(_env, options); + } + catch (Exception) + { + return; // 초기화 실패 탭 — 호스트가 로그 표시는 TitleChanged 로 알림 없이 둔다(기존 동작) + } + + var cv = tab.Core!; + cv.Settings.AreDevToolsEnabled = true; + + // 탭별 검색 세션 (네이티브 Find API) + tab.FindSession = new FindSession(new WebView2FindApi(cv, _env)); + tab.FindSession.StateChanged += () => _dispatcher.BeginInvoke(new Action(() => + { + if (tab.IsActive && tab.FindSession != null) FindStateChanged?.Invoke(tab); + })); + + // 네비게이션 상태 + cv.NavigationStarting += (_, _) => _dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true)); + cv.NavigationCompleted += (_, _) => _dispatcher.BeginInvoke(new Action(() => + { + tab.IsLoading = false; + try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌 + })); + cv.HistoryChanged += (_, _) => HistoryChanged?.Invoke(); + + cv.SourceChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() => + { + var url = cv.Source ?? ""; + tab.Url = url; + UrlChanged?.Invoke(tab, url); + })); + cv.DocumentTitleChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() => + { + TitleChanged?.Invoke(tab, cv.DocumentTitle ?? ""); + })); + + // T1-8 오디오 표시/뮤트 + try + { + cv.IsDocumentPlayingAudioChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() => + { + try { tab.IsAudioPlaying = cv.IsDocumentPlayingAudio; } catch { } + })); + cv.IsMutedChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() => + { + try { tab.IsMuted = cv.IsMuted; } catch { } + })); + } + catch { } + + // 뉴윈도우(팝업) → 새 탭 (deferral 필수 — 탭 생성이 비동기) + cv.NewWindowRequested += async (_, ne) => + { + try + { + var deferral = ne.GetDeferral(); + try + { + var newTab = NewTabProvider != null ? await NewTabProvider() : null; + if (newTab != null) + { + await WaitTabReady(newTab); + ne.NewWindow = newTab.Core; + } + ne.Handled = true; + } + finally { deferral.Complete(); } + } + catch + { + try { ne.Handled = true; if (ne.Uri != null) cv.Navigate(ne.Uri); } catch { } + } + }; + + // 페이지 내 다운로드 → 호스트 훅 + cv.DownloadStarting += (_, de) => DownloadStarting?.Invoke(cv, de); + + // 초기 URL (세션 복원) + if (!string.IsNullOrEmpty(tab.Url) && string.IsNullOrEmpty(cv.Source)) + { + try { cv.Navigate(tab.Url); } catch { } + } + + TabCoreReady?.Invoke(tab, cv); + } + + /// 활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독). + public event Action? FindStateChanged; +}