XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Text.Json;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
using Microsoft.Web.WebView2.Core;
using Windows.UI;
using XFEEDToolBox.Core.News;

namespace XFEEDToolBox.Controls;

public sealed partial class GalnetNewsPanel : UserControl
{
    private const string LocalOrigin = "https://galnet.toolbox.invalid/";
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
    private readonly GalnetNewsService news;
    private readonly GalnetMediaCatalog media;
    private readonly GalnetNewsTranslator translator;
    private readonly DispatcherQueueTimer timer;
    private readonly List<GalnetSlide> slides = [];
    private readonly HashSet<(int Generation, string Id)> previewJobs = [];
    private CancellationTokenSource? refresh, presentation;
    private GalnetNewsSnapshot? displayed;
    private DateTimeOffset nextRefresh;
    private bool active, browserReady, browserInitializing, dialogOpen;
    private int presentationGeneration;
    private WebView2? articleBrowser;
    private string? articleId;

    public GalnetNewsPanel()
    {
        InitializeComponent();
        news = new(Path.Combine(App.Runtime.DataDirectory, "galnet-news.json"));
        media = new(Path.Combine(App.Runtime.DataDirectory, "galnet-media.json"));
        translator = new(Path.Combine(App.Runtime.DataDirectory, "galnet-translations.json"));
        timer = DispatcherQueue.CreateTimer();
        timer.Interval = TimeSpan.FromMinutes(1);
        timer.Tick += (_, _) => { if (DateTimeOffset.UtcNow >= nextRefresh) _ = RefreshAsync(); };
        Loaded += (_, _) => ResumePage();
        Unloaded += (_, _) => SuspendPage();
    }

    public void ResumePage()
    {
        if (active || !IsLoaded) return;
        active = true;
        // Existing interface probes must remain offline and cannot start external requests.
        if (Environment.GetCommandLineArgs().Any(arg => arg.StartsWith("--smoke-", StringComparison.Ordinal)
            || arg.StartsWith("--accept-", StringComparison.Ordinal) || arg == "--check-navigation")) return;
        _ = InitializeCarouselAsync();
        timer.Start();
        _ = SetCarouselActiveAsync(true);
        if (DateTimeOffset.UtcNow >= nextRefresh || presentation?.IsCancellationRequested != false) _ = RefreshAsync();
    }

    public void SuspendPage()
    {
        active = false;
        timer.Stop();
        refresh?.Cancel(); presentation?.Cancel();
        _ = SetCarouselActiveAsync(false);
    }

    private async Task InitializeCarouselAsync()
    {
        if (browserReady || browserInitializing) return;
        browserInitializing = true;
        try { await InitializeBrowserAsync(NewsBrowser, false); }
        catch (Exception error) { App.Runtime.Log("银河新闻界面", error.Message); NewsStatus.Text = "新闻界面暂时无法载入,请刷新重试。"; }
        finally { browserInitializing = false; }
    }

    private async Task InitializeBrowserAsync(WebView2 browser, bool article)
    {
        var environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, Path.Combine(App.Runtime.DataDirectory, "webview-galnet"), null);
        await browser.EnsureCoreWebView2Async(environment);
        var core = browser.CoreWebView2;
        core.SetVirtualHostNameToFolderMapping("galnet.toolbox.invalid", Path.Combine(AppContext.BaseDirectory, "Assets", "Galnet"), CoreWebView2HostResourceAccessKind.DenyCors);
        core.Settings.IsStatusBarEnabled = false;
        core.Settings.IsZoomControlEnabled = false;
        core.NavigationStarting += (_, args) => { if (!args.Uri.StartsWith(LocalOrigin, StringComparison.Ordinal)) args.Cancel = true; };
        core.NewWindowRequested += (_, args) => args.Handled = true;
        core.WebMessageReceived += async (_, args) =>
        {
            if (!args.Source.StartsWith(LocalOrigin, StringComparison.Ordinal)) return;
            try
            {
                using var json = JsonDocument.Parse(args.WebMessageAsJson);
                var root = json.RootElement;
                var type = root.GetProperty("type").GetString();
                var id = root.TryGetProperty("id", out var idValue) ? idValue.GetString() : null;
                if (type == "ready")
                {
                    if (!article) { browserReady = true; await PushSlidesAsync(); await SetCarouselActiveAsync(active && !dialogOpen); }
                    else await PushArticleAsync();
                }
                else if (id is not null && slides.Any(s => s.Id == id))
                {
                    if (type == "select") QueuePreviews(id);
                    else if (type == "open" && !article) await OpenArticleAsync(id);
                    else if (type == "official" && article && slides.First(s => s.Id == id).Article.SourceUri is { } source)
                        await Windows.System.Launcher.LaunchUriAsync(source);
                }
            }
            catch (Exception error) { App.Runtime.Log("银河新闻界面", error.Message); }
        };
        core.Navigate(LocalOrigin + "index.html");
    }

    private async void RefreshClicked(object sender, RoutedEventArgs args)
    {
        if (refresh is not null) { refresh.Cancel(); return; }
        await InitializeCarouselAsync();
        await RefreshAsync();
    }

    private async Task RefreshAsync()
    {
        if (!active || refresh is not null) return;
        using var operation = new CancellationTokenSource();
        refresh = operation; SetBusy(true);
        nextRefresh = DateTimeOffset.UtcNow.AddMinutes(1);
        try
        {
            var saved = await news.LoadCachedAsync(operation.Token);
            ShowEdition(saved);
            NewsStatus.Text = saved is null ? "正在获取银河新闻…" : "正在检查新新闻,已保存的内容仍可阅读。";
            var result = await news.RefreshAsync(operation.Token);
            operation.Token.ThrowIfCancellationRequested();
            ShowEdition(result.Snapshot);
            NewsStatus.Text = result.Message + " · 自动翻译为中文 · 鼠标停留时暂停轮播";
            if (!result.Message.StartsWith("暂时", StringComparison.Ordinal)) nextRefresh = DateTimeOffset.UtcNow.AddMinutes(10);
        }
        catch (OperationCanceledException)
        {
            NewsStatus.Text = displayed is null ? "已取消获取新闻。" : "已取消刷新,继续显示已保存的内容。";
            nextRefresh = DateTimeOffset.UtcNow.AddMinutes(1);
        }
        catch (Exception error)
        {
            App.Runtime.Log("银河新闻", error.Message);
            NewsStatus.Text = displayed is null ? "暂时无法获取银河新闻,稍后会自动重试。" : "暂时无法获取新新闻,继续显示已保存的内容。";
        }
        finally { refresh = null; SetBusy(false); if (active && operation.IsCancellationRequested) nextRefresh = DateTimeOffset.UtcNow; }
    }

    private void ShowEdition(GalnetNewsSnapshot? snapshot)
    {
        if (snapshot is null || ReferenceEquals(displayed, snapshot) && presentation?.IsCancellationRequested == false) return;
        displayed = snapshot;
        presentation?.Cancel(); presentation?.Dispose(); presentation = new();
        var generation = ++presentationGeneration;
        EditionText.Text = $"Galnet · Frontier 官方新闻 · {snapshot.RetrievedAt.ToLocalTime():MM-dd HH:mm} 更新";
        _ = PrepareSlidesAsync(snapshot, generation, presentation.Token);
    }

    private async Task PrepareSlidesAsync(GalnetNewsSnapshot snapshot, int generation, CancellationToken token)
    {
        try
        {
            await media.LoadAsync(token);
            var prepared = new List<GalnetSlide>();
            foreach (var article in snapshot.Articles.Take(6))
            {
                var preview = GalnetSlide.Preview(article.Body);
                var title = await translator.CachedAsync(article.Title, token);
                var teaser = await translator.CachedAsync(preview, token);
                var body = await translator.CachedAsync(article.Body, token);
                prepared.Add(new(article, media.Image(article), title ?? article.Title, teaser ?? preview, body ?? article.Body, title is not null && teaser is not null, body is not null));
            }
            token.ThrowIfCancellationRequested(); if (generation != presentationGeneration) return;
            slides.Clear(); slides.AddRange(prepared); await PushSlidesAsync();
            if (slides.Count > 0) QueuePreviews(slides[0].Id);
            try
            {
                await media.RefreshAsync(snapshot, token);
                token.ThrowIfCancellationRequested(); if (generation != presentationGeneration) return;
                for (var i = 0; i < slides.Count; i++) slides[i] = slides[i] with { Image = media.Image(slides[i].Article) };
                await PushSlidesAsync(); await PushArticleAsync();
            }
            catch (Exception error) when (error is not OperationCanceledException) { App.Runtime.Log("银河新闻配图", error.Message); }
        }
        catch (OperationCanceledException) { }
        catch (Exception error) { App.Runtime.Log("银河新闻展示", error.Message); }
    }

    private void QueuePreviews(string id)
    {
        if (!active || presentation is null || presentation.IsCancellationRequested) return;
        var index = slides.FindIndex(s => s.Id == id); if (index < 0) return;
        _ = TranslatePreviewAsync(id, presentationGeneration, presentation.Token);
        if (slides.Count > 1) _ = TranslatePreviewAsync(slides[(index + 1) % slides.Count].Id, presentationGeneration, presentation.Token);
    }

    private async Task TranslatePreviewAsync(string id, int generation, CancellationToken token)
    {
        if (!previewJobs.Add((generation, id))) return;
        try
        {
            var article = slides.FirstOrDefault(s => s.Id == id)?.Article; if (article is null) return;
            var title = await translator.TranslateAsync(article.Title, token);
            await UpdateSlideAsync(id, generation, s => s with { Title = title });
            var teaser = await translator.TranslateAsync(GalnetSlide.Preview(article.Body), token);
            await UpdateSlideAsync(id, generation, s => s with { Teaser = teaser, PreviewTranslated = true, TranslationStatus = "中文 · 自动翻译" });
        }
        catch (OperationCanceledException) { }
        catch (Exception error)
        {
            App.Runtime.Log("银河新闻翻译", error.Message);
            await UpdateSlideAsync(id, generation, s => s with { TranslationStatus = "翻译暂不可用 · 可阅读原文" });
        }
        finally { previewJobs.Remove((generation, id)); }
    }

    private async Task UpdateSlideAsync(string id, int generation, Func<GalnetSlide, GalnetSlide> update)
    {
        if (generation != presentationGeneration || !active) return;
        var index = slides.FindIndex(s => s.Id == id); if (index < 0) return;
        slides[index] = update(slides[index]); await PushSlidesAsync(); if (articleId == id) await PushArticleAsync();
    }
    private async Task PushSlidesAsync()
    {
        if (browserReady) await NewsBrowser.ExecuteScriptAsync("window.galnet?.setSlides(" + JsonSerializer.Serialize(slides, JsonOptions) + ")");
    }
    private async Task SetCarouselActiveAsync(bool value)
    {
        try { if (browserReady) await NewsBrowser.ExecuteScriptAsync("window.galnet?.setActive(" + (value ? "true" : "false") + ")"); }
        catch (Exception error) { App.Runtime.Log("银河新闻轮播", error.Message); }
    }
    private async Task PushArticleAsync()
    {
        if (articleBrowser?.CoreWebView2 is null || slides.FirstOrDefault(s => s.Id == articleId) is not { } slide) return;
        await articleBrowser.ExecuteScriptAsync("window.galnet?.setArticle(" + JsonSerializer.Serialize(slide, JsonOptions) + ")");
    }
    private async Task TranslateBodyAsync(string id, int generation, CancellationToken token)
    {
        try
        {
            var slide = slides.First(s => s.Id == id);
            var title = await translator.TranslateAsync(slide.Article.Title, token);
            var body = await translator.TranslateAsync(slide.Article.Body, token);
            await UpdateSlideAsync(id, generation, s => s with { Title = title, Body = body, BodyTranslated = true, TranslationStatus = "中文 · 自动翻译" });
        }
        catch (OperationCanceledException) { }
        catch (Exception error)
        {
            App.Runtime.Log("银河新闻全文翻译", error.Message);
            await UpdateSlideAsync(id, generation, s => s with { TranslationStatus = "翻译暂不可用 · 可阅读原文" });
        }
    }
    private async Task OpenArticleAsync(string id)
    {
        if (dialogOpen || !active || presentation is null) return;
        dialogOpen = true; articleId = id;
        await SetCarouselActiveAsync(false);
        var readerRoot = XamlRoot;
        var readerWidth = Math.Clamp(readerRoot.Size.Width - 100, 320, 1280);
        // WebView2 has no useful intrinsic desired width. Give the dialog's
        // auto-sized content a real width instead of its 320px minimum layout.
        var browser = new WebView2 { DefaultBackgroundColor = Color.FromArgb(255, 3, 3, 5), Width = readerWidth - 48,
            Height = Math.Clamp(readerRoot.Size.Height - 200, 260, 900), HorizontalAlignment = HorizontalAlignment.Stretch };
        var dialog = new ContentDialog { Title = "GALNET · 银河新闻", CloseButtonText = "关闭", Content = browser, XamlRoot = readerRoot,
            Width = readerWidth, MaxWidth = 1280, HorizontalContentAlignment = HorizontalAlignment.Stretch, Background = new SolidColorBrush(Color.FromArgb(255, 3, 3, 5)) };
        dialog.Resources["ContentDialogMaxWidth"] = 1280d;
        void ResizeReader(XamlRoot root, XamlRootChangedEventArgs args)
        {
            dialog.Width = Math.Clamp(root.Size.Width - 100, 320, 1280);
            browser.Width = dialog.Width - 48; browser.Height = Math.Clamp(root.Size.Height - 200, 260, 900);
        }
        readerRoot.Changed += ResizeReader;
        articleBrowser = browser;
        dialog.Opened += async (_, _) =>
        {
            try
            {
                await InitializeBrowserAsync(browser, true);
                _ = TranslateBodyAsync(id, presentationGeneration, presentation.Token);
            }
            catch (Exception error) { App.Runtime.Log("银河新闻阅读", error.Message); }
        };
        try { await dialog.ShowAsync(); }
        finally { readerRoot.Changed -= ResizeReader; articleBrowser = null; articleId = null; browser.Close(); dialogOpen = false; await SetCarouselActiveAsync(active); }
    }

    private void SetBusy(bool busy)
    {
        RefreshText.Text = busy ? "取消" : "刷新";
        RefreshIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
        RefreshProgress.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
        RefreshProgress.IsActive = busy;
        Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(RefreshButton, busy ? "取消刷新银河新闻" : "刷新银河新闻");
    }
}