253 lines
11 KiB
C#
253 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 탭별 WebView2 생명주기·이벤트 배선 전담(재구성 ④ — MainWindow 갓-클래스 해체).
|
|
/// 일반 브라우저 동작(제목/URL/로딩/오디오/검색세션/줌 적용/초기 탐색/뉴윈도우→탭)은
|
|
/// 이 서비스가, 앱 고유 기능(미디어 감지/다운로드 라우팅/소셜/기록/주소창 반영)은
|
|
/// 이벤트 훅으로 호스트(MainWindow)가 주입한다.
|
|
/// </summary>
|
|
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<TabItem, Task> _init = new();
|
|
|
|
/// <summary>뉴윈도우(팝업) 요청 시 새 탭을 만들어 반환하는 공급자(호스트가 TabManager 에 연결).</summary>
|
|
public Func<Task<TabItem>>? NewTabProvider { get; set; }
|
|
|
|
/// <summary>탭 CoreWebView2 초기화 완료(호스트가 감지 등 탭별 구독 부착).</summary>
|
|
public event Action<TabItem, CoreWebView2>? TabCoreReady;
|
|
|
|
/// <summary>페이지 내 a[download] 등 다운로드 시작(호스트가 저장 폴더로 라우팅).</summary>
|
|
public event Action<CoreWebView2, CoreWebView2DownloadStartingEventArgs>? DownloadStarting;
|
|
|
|
/// <summary>URL 확정(SourceChanged). 인자: (탭, url).</summary>
|
|
public event Action<TabItem, string>? UrlChanged;
|
|
|
|
/// <summary>문서 타이틀 확정(DocumentTitleChanged). 인자: (탭, title).</summary>
|
|
public event Action<TabItem, string>? TitleChanged;
|
|
|
|
/// <summary>탭 뒤로/앞으로 가능 상태 변화(네비 버튼 갱신용).</summary>
|
|
public event Action? HistoryChanged;
|
|
|
|
/// <summary>우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true).</summary>
|
|
public event Action<CoreWebView2, CoreWebView2ContextMenuRequestedEventArgs>? ContextMenuBuild;
|
|
|
|
/// <summary>T2-7 부스트: URL → 주입할 JS(강제 다크/커스텀 CSS). 빈 문자열이면 미적용.</summary>
|
|
public Func<string?, string>? BoostScriptProvider { get; set; }
|
|
|
|
/// <summary>T2-6 Glance: Alt+클릭한 링크 URL 요청.</summary>
|
|
public event Action<CoreWebView2, string>? GlanceRequested;
|
|
|
|
/// <summary>T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.</summary>
|
|
public event Action<CoreWebView2, string>? SpeakRequested;
|
|
|
|
/// <summary>T3-3 InPrivate 탭: 다음에 만들어지는 탭에 적용(1회성).</summary>
|
|
public bool NextTabIsPrivate { get; set; }
|
|
|
|
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();
|
|
|
|
/// <summary>탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>부스트 즉시 재적용(토글 직후). 비동기 fire-and-forget.</summary>
|
|
public void ApplyBoostNow(TabItem tab)
|
|
{
|
|
var cv = tab.Core;
|
|
if (cv == null) return;
|
|
var js = BoostScriptProvider?.Invoke(cv.Source) ?? "";
|
|
if (js.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(js);
|
|
else if (js.Length == 0) _ = cv.ExecuteScriptAsync("(function(){try{document.getElementById('vd-boost')?.remove();}catch(e){}})();");
|
|
}
|
|
|
|
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>탭 제거 시 뷰를 호스트에서 떼고 재오픈 가능 상태로(null) 만든다.</summary>
|
|
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 || tab.IsPrivate || NextTabIsPrivate) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
|
|
if (!tab.IsPrivate && NextTabIsPrivate) tab.IsPrivate = true;
|
|
NextTabIsPrivate = false;
|
|
await tab.View!.EnsureCoreWebView2Async(_env, options);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return; // 초기화 실패 탭 — 호스트가 로그 표시는 TitleChanged 로 알림 없이 둔다(기존 동작)
|
|
}
|
|
|
|
var cv = tab.Core!;
|
|
cv.Settings.AreDevToolsEnabled = true;
|
|
|
|
// T2-6 Glance: Alt+클릭 → 링크를 postMessage 로 전달 (캡처 단계에서 preventDefault)
|
|
const string glanceScript = """
|
|
(function(){ try { document.addEventListener('click', function(e){
|
|
if (e.altKey) { var a = e.target && e.target.closest ? e.target.closest('a') : null;
|
|
if (a && a.href) { e.preventDefault(); e.stopPropagation(); window.chrome.webview.postMessage({ vdGlance: a.href }); } }
|
|
}, true); } catch(err){} })();
|
|
""";
|
|
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(glanceScript); } catch { }
|
|
|
|
// WebMessage 라우팅 (Glance / 리더 낭독)
|
|
try
|
|
{
|
|
cv.WebMessageReceived += (_, we) =>
|
|
{
|
|
try
|
|
{
|
|
var msg = we.WebMessageAsJson;
|
|
if (string.IsNullOrEmpty(msg)) return;
|
|
using var doc = System.Text.Json.JsonDocument.Parse(msg);
|
|
if (doc.RootElement.TryGetProperty("vdGlance", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.String)
|
|
GlanceRequested?.Invoke(cv, g.GetString() ?? "");
|
|
if (doc.RootElement.TryGetProperty("vdSpeak", out var sp) && sp.ValueKind == System.Text.Json.JsonValueKind.String)
|
|
SpeakRequested?.Invoke(cv, sp.GetString() ?? "");
|
|
}
|
|
catch { }
|
|
};
|
|
}
|
|
catch { }
|
|
|
|
// 탭별 검색 세션 (네이티브 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 사이트별 줌
|
|
try // T2-7 부스트(강제 다크/커스텀 CSS) 적용
|
|
{
|
|
var boostJs = BoostScriptProvider?.Invoke(cv.Source) ?? "";
|
|
if (boostJs.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(boostJs);
|
|
}
|
|
catch { }
|
|
}));
|
|
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);
|
|
|
|
// 우클릭 메뉴 → 호스트가 커스텀 메뉴 구성(기본 메뉴 대체, T2-12/T3-5)
|
|
cv.ContextMenuRequested += (_, ce) =>
|
|
{
|
|
ce.Handled = true;
|
|
ContextMenuBuild?.Invoke(cv, ce);
|
|
};
|
|
|
|
// 초기 URL (세션 복원)
|
|
if (!string.IsNullOrEmpty(tab.Url) && string.IsNullOrEmpty(cv.Source))
|
|
{
|
|
try { cv.Navigate(tab.Url); } catch { }
|
|
}
|
|
|
|
TabCoreReady?.Invoke(tab, cv);
|
|
}
|
|
|
|
/// <summary>활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독).</summary>
|
|
public event Action<TabItem>? FindStateChanged;
|
|
}
|