更新 update 门户站点界面和后台功能
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-27 18:09:11 +08:00
parent 2513eb2903
commit 962a2f2143
56 changed files with 4564 additions and 714 deletions
+46 -4
View File
@@ -61,13 +61,18 @@ public sealed record RemoteMediaSource(
string Name,
string Description,
string ApiUrl,
string ResolvedUrl,
string ResolvedKey,
string MediaType,
string ThumbnailUrl,
bool Downloadable,
int RefreshIntervalSeconds,
IReadOnlyList<string> SupportedFormats,
RemoteMediaKind Kind)
{
public bool IsAvailable => Uri.TryCreate(ApiUrl, UriKind.Absolute, out _);
public string EffectiveApiUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? ApiUrl : ResolvedUrl;
public bool IsAvailable => Uri.TryCreate(EffectiveApiUrl, UriKind.Absolute, out _);
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
@@ -132,7 +137,11 @@ public static class RemoteMediaCatalogParser
}
var id = JsonString(categoryElement, "id");
var categoryKind = InferKind(id, []);
var categoryKind = ParseKind(JsonString(categoryElement, "kind", "type", "mediaType", "media_type"));
if (categoryKind == RemoteMediaKind.Unknown)
{
categoryKind = InferKind(id, []);
}
var sources = ParseSources(categoryElement, categoryKind);
if (categoryKind == RemoteMediaKind.Unknown)
{
@@ -316,7 +325,12 @@ public static class RemoteMediaCatalogParser
var id = JsonString(sourceElement, "id");
var formats = JsonStringArray(sourceElement, "supported_formats", "supportedFormats");
var kind = InferKind(id, formats);
var mediaType = JsonString(sourceElement, "mediaType", "media_type", "kind", "type");
var kind = ParseKind(mediaType);
if (kind == RemoteMediaKind.Unknown)
{
kind = InferKind(id, formats);
}
if (kind == RemoteMediaKind.Unknown)
{
kind = categoryKind;
@@ -328,13 +342,18 @@ public static class RemoteMediaCatalogParser
}
var apiUrl = JsonString(sourceElement, "api_url", "apiUrl", "url");
var resolvedUrl = JsonString(sourceElement, "resolvedUrl", "resolved_url");
var resolvedKey = JsonString(sourceElement, "resolvedKey", "resolved_key");
var thumbnailUrl = JsonString(sourceElement, "thumbnail_url", "thumbnailUrl", "thumbnail", "cover");
sources.Add(new RemoteMediaSource(
Id: string.IsNullOrWhiteSpace(id) ? $"source-{sources.Count + 1}" : id,
Name: JsonString(sourceElement, "name"),
Description: JsonString(sourceElement, "description"),
ApiUrl: apiUrl,
ThumbnailUrl: string.IsNullOrWhiteSpace(thumbnailUrl) ? apiUrl : thumbnailUrl,
ResolvedUrl: resolvedUrl,
ResolvedKey: resolvedKey,
MediaType: string.IsNullOrWhiteSpace(mediaType) ? KindName(kind) : mediaType,
ThumbnailUrl: string.IsNullOrWhiteSpace(thumbnailUrl) ? (string.IsNullOrWhiteSpace(resolvedUrl) ? apiUrl : resolvedUrl) : thumbnailUrl,
Downloadable: JsonBool(sourceElement, true, "downloadable"),
RefreshIntervalSeconds: NormalizedRefreshInterval(sourceElement, kind),
SupportedFormats: formats,
@@ -437,6 +456,29 @@ public static class RemoteMediaCatalogParser
return kind;
}
private static RemoteMediaKind ParseKind(string value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"image" or "img" or "picture" or "photo" => RemoteMediaKind.Image,
"video" or "movie" or "mp4" => RemoteMediaKind.Video,
"audio" or "music" or "mp3" => RemoteMediaKind.Audio,
_ => RemoteMediaKind.Unknown
};
}
private static string KindName(RemoteMediaKind kind)
{
return kind switch
{
RemoteMediaKind.Image => "image",
RemoteMediaKind.Video => "video",
RemoteMediaKind.Audio => "audio",
_ => string.Empty
};
}
private static string JsonString(JsonElement root, params string[] names)
{
if (!TryGet(root, out var value, names))
@@ -22,8 +22,12 @@ public sealed class RemoteMediaCatalogService(
ILogService? logService = null) : IRemoteMediaCatalogService
{
public static readonly Uri PrimaryConfigUri = new("https://update.ymhut.cn/media-types.json");
public static readonly Uri BootstrapUri = new("https://update.ymhut.cn/api/client/bootstrap");
public static readonly Uri SourcesUri = new("https://update.ymhut.cn/api/client/sources");
private const string EndpointId = "media_types";
private const string BootstrapEndpointId = "client_bootstrap";
private const string SourcesEndpointId = "client_sources";
private const string SnapshotFileName = "media-types.json";
public string CacheDirectory => Path.Combine(paths.Cache, "remote-media");
@@ -35,18 +39,7 @@ public sealed class RemoteMediaCatalogService(
string? warning = null;
try
{
var response = forceRefresh
? await apiManager.FetchUriAsync(EndpointId, AddCacheBuster(PrimaryConfigUri), string.Empty, cancellationToken).ConfigureAwait(false)
: await apiManager.FetchAsync(EndpointId, string.Empty, cancellationToken).ConfigureAwait(false);
if (!response.Success)
{
throw new InvalidOperationException(response.Error ?? "Remote media configuration request failed.");
}
var catalog = RemoteMediaCatalogParser.Parse(response.Content, response.FetchedAt);
await WriteSnapshotAsync(response.Content, cancellationToken).ConfigureAwait(false);
return new RemoteMediaCatalogLoadResult(catalog, RemoteMediaCatalogLoadSource.Remote);
return await LoadRemoteCatalogAsync(forceRefresh, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is HttpRequestException or IOException or JsonException or InvalidDataException or TaskCanceledException or InvalidOperationException)
{
@@ -63,6 +56,97 @@ public sealed class RemoteMediaCatalogService(
throw new InvalidOperationException(warning ?? "Remote media configuration is unavailable and no local snapshot exists.");
}
private async Task<RemoteMediaCatalogLoadResult> LoadRemoteCatalogAsync(bool forceRefresh, CancellationToken cancellationToken)
{
var attempts = new[]
{
(EndpointId: BootstrapEndpointId, Uri: BootstrapUri, Legacy: false),
(EndpointId: SourcesEndpointId, Uri: SourcesUri, Legacy: false),
(EndpointId: EndpointId, Uri: PrimaryConfigUri, Legacy: true)
};
var errors = new List<string>();
foreach (var attempt in attempts)
{
var uri = forceRefresh ? AddCacheBuster(attempt.Uri) : attempt.Uri;
var response = attempt.Legacy && !forceRefresh
? await apiManager.FetchAsync(EndpointId, string.Empty, cancellationToken).ConfigureAwait(false)
: await apiManager.FetchUriAsync(attempt.EndpointId, uri, string.Empty, cancellationToken).ConfigureAwait(false);
if (!response.Success)
{
errors.Add($"{attempt.Uri.AbsolutePath}: {response.Error ?? response.StatusCode.ToString()}");
continue;
}
try
{
var content = ExtractCatalogContent(response.Content);
var catalog = RemoteMediaCatalogParser.Parse(content, response.FetchedAt);
await WriteSnapshotAsync(content, cancellationToken).ConfigureAwait(false);
return new RemoteMediaCatalogLoadResult(catalog, RemoteMediaCatalogLoadSource.Remote);
}
catch (Exception exception) when (exception is JsonException or InvalidDataException)
{
errors.Add($"{attempt.Uri.AbsolutePath}: {exception.Message}");
}
}
throw new InvalidOperationException(errors.Count == 0
? "Remote media configuration request failed."
: string.Join(" | ", errors));
}
private static string ExtractCatalogContent(string content)
{
using var document = JsonDocument.Parse(content, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidDataException("Remote media configuration does not contain an object.");
}
if (root.TryGetProperty("categories", out var categories) && categories.ValueKind == JsonValueKind.Array)
{
return content;
}
if (TryGetObject(root, out var sources, "sources", "catalog", "mediaTypes", "media_types") &&
sources.TryGetProperty("categories", out var nestedCategories) &&
nestedCategories.ValueKind == JsonValueKind.Array)
{
return sources.GetRawText();
}
throw new InvalidDataException("Remote media configuration does not contain categories.");
}
private static bool TryGetObject(JsonElement root, out JsonElement value, params string[] names)
{
value = default;
foreach (var name in names)
{
if (root.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Object)
{
return true;
}
}
foreach (var property in root.EnumerateObject())
{
if (names.Any(name => string.Equals(name, property.Name, StringComparison.OrdinalIgnoreCase)) &&
property.Value.ValueKind == JsonValueKind.Object)
{
value = property.Value;
return true;
}
}
return false;
}
public async Task<RemoteMediaCatalogLoadResult?> TryReadCacheAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(SnapshotPath))
@@ -133,7 +133,7 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
}
catch when (!cancellationToken.IsCancellationRequested)
{
return lastResolution ?? FromUriOnly(current, expectedKind);
return lastResolution ?? FromProbeFailure(current, expectedKind);
}
}
@@ -194,9 +194,20 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
extension);
}
private static RemoteMediaResolution FromProbeFailure(Uri uri, RemoteMediaKind expectedKind)
{
var extension = SuggestedExtension(uri, string.Empty, expectedKind);
return new RemoteMediaResolution(
uri,
"application/x-ymhut-probe-failed",
null,
false,
extension);
}
private static bool IsDirectMedia(Uri uri, string contentType, long? length, RemoteMediaKind expectedKind)
{
if (IsMediaContentType(contentType) || LooksLikeDirectMediaUri(uri, expectedKind))
if (IsExpectedMediaContentType(contentType, expectedKind) || LooksLikeDirectMediaUri(uri, expectedKind))
{
return true;
}
@@ -252,8 +263,23 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
private static bool IsImageExtension(string extension)
=> extension is "png" or "jpg" or "jpeg" or "bmp" or "gif" or "webp" or "tif" or "tiff";
private static bool IsMediaContentType(string contentType)
private static bool IsExpectedMediaContentType(string contentType, RemoteMediaKind expectedKind)
{
if (expectedKind == RemoteMediaKind.Image)
{
return contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
}
if (expectedKind == RemoteMediaKind.Video)
{
return contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
}
if (expectedKind == RemoteMediaKind.Audio)
{
return contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase);
}
return contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ||
contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ||
contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase);
@@ -84,6 +84,8 @@ public sealed class AppSettings
public bool UpdateNotification { get; set; } = true;
public double RandomCinemaVolumePercent { get; set; } = 70;
public bool HardwareAccelerationEnabled { get; set; } = true;
public int ProxyTestTimeoutSeconds { get; set; } = 6;
+133 -5
View File
@@ -18,15 +18,19 @@ public sealed class RemoteMediaCatalogTests
var image = catalog.Categories.Single(category => category.Id == "image");
Assert.IsTrue(image.Enabled);
Assert.AreEqual("随机图片", image.DisplayName);
Assert.AreEqual(RemoteMediaKind.Image, image.Kind);
Assert.IsTrue(image.Layout.ShowPreview);
CollectionAssert.Contains(image.Sources.First(source => source.Id == "xjj").SupportedFormats.ToArray(), "jpg");
Assert.AreEqual(30, image.Sources.First(source => source.Id == "xjj").RefreshIntervalSeconds);
Assert.AreEqual("image", image.Sources.First(source => source.Id == "xjj").MediaType);
var video = catalog.Categories.Single(category => category.Id == "video");
Assert.AreEqual("随机视频", video.DisplayName);
Assert.AreEqual(RemoteMediaKind.Video, video.Kind);
Assert.IsFalse(video.Layout.AutoPlay);
CollectionAssert.Contains(video.Sources.First().SupportedFormats.ToArray(), "mp4");
Assert.AreEqual("video", video.Sources.First().MediaType);
}
[TestMethod]
@@ -75,6 +79,74 @@ public sealed class RemoteMediaCatalogTests
Assert.AreEqual("https://example.test/media", source.ThumbnailUrl);
}
[TestMethod]
public void ParsesUnifiedResolvedMediaFields()
{
const string content = """
{
"categories": [
{
"id": "image",
"subcategories": [
{
"id": "demo",
"api_url": "https://api.example.test/random",
"resolvedUrl": "https://cdn.example.test/media/demo.webp",
"resolvedKey": "data.cover",
"mediaType": "image",
"supported_formats": ["json", "webp"]
}
]
}
]
}
""";
var source = RemoteMediaCatalogParser.Parse(content).Categories.Single().Sources.Single();
Assert.AreEqual("https://api.example.test/random", source.ApiUrl);
Assert.AreEqual("https://cdn.example.test/media/demo.webp", source.ResolvedUrl);
Assert.AreEqual("data.cover", source.ResolvedKey);
Assert.AreEqual("image", source.MediaType);
Assert.AreEqual(source.ResolvedUrl, source.EffectiveApiUrl);
Assert.IsTrue(source.IsAvailable);
}
[TestMethod]
public void ExplicitMediaTypeWinsOverCategoryAndFormats()
{
const string content = """
{
"categories": [
{
"id": "mixed",
"type": "image",
"subcategories": [
{
"id": "json_picture",
"api_url": "https://api.example.test/random",
"mediaType": "image",
"supported_formats": ["json", "mp4"]
},
{
"id": "clip",
"api_url": "https://api.example.test/clip",
"type": "video",
"supported_formats": ["jpg"]
}
]
}
]
}
""";
var category = RemoteMediaCatalogParser.Parse(content).Categories.Single();
Assert.AreEqual(RemoteMediaKind.Image, category.Kind);
Assert.AreEqual(RemoteMediaKind.Image, category.Sources[0].Kind);
Assert.AreEqual(RemoteMediaKind.Video, category.Sources[1].Kind);
}
[TestMethod]
public async Task ServiceWritesReadsFallsBackAndClearsCache()
{
@@ -112,6 +184,58 @@ public sealed class RemoteMediaCatalogTests
}
}
[TestMethod]
public async Task ServicePrefersUnifiedBootstrapSources()
{
var root = Path.Combine(Path.GetTempPath(), "ymhut-remote-media-" + Guid.NewGuid().ToString("N"));
try
{
var paths = new AppPaths(root);
paths.EnsureCreated();
const string bootstrap = """
{
"ok": true,
"sources": {
"layout_version": "2.0.0",
"categories": [
{
"id": "image",
"subcategories": [
{
"id": "demo",
"api_url": "https://api.example.test/random",
"resolvedUrl": "https://cdn.example.test/media/demo.webp",
"supported_formats": ["json", "webp"]
}
]
}
]
}
}
""";
var api = new FakeApiManager(
string.Empty,
uriResponses: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[RemoteMediaCatalogService.BootstrapUri.AbsolutePath] = bootstrap
});
var service = new RemoteMediaCatalogService(paths, api);
var result = await service.LoadAsync(forceRefresh: false);
Assert.AreEqual(RemoteMediaCatalogLoadSource.Remote, result.Source);
Assert.AreEqual(RemoteMediaCatalogService.BootstrapUri.AbsolutePath, api.LastUri?.AbsolutePath);
Assert.AreEqual("https://cdn.example.test/media/demo.webp", result.Catalog.Categories.Single().Sources.Single().EffectiveApiUrl);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
private static string ReadRepoFile(params string[] segments)
{
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
@@ -129,7 +253,7 @@ public sealed class RemoteMediaCatalogTests
throw new DirectoryNotFoundException("Unable to locate repository sample file.");
}
private sealed class FakeApiManager(string content, bool success = true) : IApiManager
private sealed class FakeApiManager(string content, bool success = true, IReadOnlyDictionary<string, string>? uriResponses = null) : IApiManager
{
public Uri? LastUri { get; private set; }
@@ -149,14 +273,18 @@ public sealed class RemoteMediaCatalogTests
public Task<ApiResponse> FetchUriAsync(string endpointId, Uri uri, string input = "", CancellationToken cancellationToken = default)
{
LastUri = uri;
var responseContent = uriResponses is not null && uriResponses.TryGetValue(uri.AbsolutePath, out var match)
? match
: content;
var responseSuccess = success || !string.IsNullOrWhiteSpace(responseContent);
return Task.FromResult(new ApiResponse(
endpointId,
uri,
success,
success ? content : string.Empty,
success ? null : "offline",
responseSuccess,
responseSuccess ? responseContent : string.Empty,
responseSuccess ? null : "offline",
DateTimeOffset.Now,
success ? 200 : 0));
responseSuccess ? 200 : 0));
}
public Task<ApiHealthStatus> CheckHealthAsync(string endpointId, string input = "", CancellationToken cancellationToken = default)
@@ -85,6 +85,30 @@ public sealed class RemoteMediaResolverTests
Assert.AreEqual("text/html", result.ContentType);
}
[TestMethod]
public async Task ProbeFailureDoesNotPretendUrlIsDirectMedia()
{
var resolver = CreateResolver(_ => throw new HttpRequestException("SSL connection failed"));
var result = await resolver.ResolveMediaAsync("https://cdn.test/broken/video.mp4", RemoteMediaKind.Video, cacheBust: false);
Assert.AreEqual("https://cdn.test/broken/video.mp4", result.Uri.AbsoluteUri);
Assert.IsFalse(result.IsDirectMedia);
Assert.AreEqual("application/x-ymhut-probe-failed", result.ContentType);
Assert.AreEqual(".mp4", result.SuggestedExtension);
}
[TestMethod]
public async Task RejectsWrongKindMediaAsNonDirectForExpectedKind()
{
var resolver = CreateResolver(_ => Text(HttpStatusCode.OK, string.Empty, "image/jpeg"));
var result = await resolver.ResolveMediaAsync("https://example.test/random", RemoteMediaKind.Video, cacheBust: false);
Assert.IsFalse(result.IsDirectMedia);
Assert.AreEqual("image/jpeg", result.ContentType);
}
[TestMethod]
public async Task TreatsDirectMediaContentTypeAsPlayable()
{
+10 -10
View File
@@ -708,26 +708,26 @@ public sealed class ToolExecutorTests
public void UpdateNoticeJsonKeepsPlainTextAndAddsMarkdown()
{
var repoRoot = Directory.GetParent(FindAssetsRoot())!.FullName;
var noticePath = Path.Combine(repoRoot, "update-notice", "2.0.6.3.json");
var noticePath = Path.Combine(repoRoot, "update-notice", "2.0.7.5.json");
var totalPath = Path.Combine(repoRoot, "update-notice", "total.json");
using var notice = JsonDocument.Parse(File.ReadAllText(noticePath));
var noticeRoot = notice.RootElement;
Assert.AreEqual("2.0.6.3", noticeRoot.GetProperty("app_version").GetString());
Assert.AreEqual("2.0.7.5", noticeRoot.GetProperty("app_version").GetString());
Assert.IsFalse(string.IsNullOrWhiteSpace(noticeRoot.GetProperty("message").GetString()));
Assert.IsFalse(string.IsNullOrWhiteSpace(noticeRoot.GetProperty("release_notes").GetString()));
StringAssert.Contains(noticeRoot.GetProperty("message_md").GetString(), "YMhut Box 2.0.6.3");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "QQ 信息");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "安全浏览器");
StringAssert.Contains(noticeRoot.GetProperty("message_md").GetString(), "YMhut Box 2.0.7.5");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "随机放映室");
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "音量控制");
using var total = JsonDocument.Parse(File.ReadAllText(totalPath));
var totalRoot = total.RootElement;
Assert.AreEqual("2.0.6.3", totalRoot.GetProperty("latest_version").GetString());
Assert.AreEqual("2.0.7.5", totalRoot.GetProperty("latest_version").GetString());
var latest = totalRoot.GetProperty("latest");
Assert.AreEqual("2.0.6.3", latest.GetProperty("version").GetString());
StringAssert.Contains(latest.GetProperty("release_notes_md").GetString(), "QQ 信息");
Assert.AreEqual("2.0.6.3", totalRoot.GetProperty("versions")[0].GetProperty("version").GetString());
StringAssert.Contains(totalRoot.GetProperty("versions")[0].GetProperty("summary").GetString(), "QQ 信息");
Assert.AreEqual("2.0.7.5", latest.GetProperty("version").GetString());
StringAssert.Contains(latest.GetProperty("release_notes_md").GetString(), "随机放映室");
Assert.AreEqual("2.0.7.5", totalRoot.GetProperty("versions")[0].GetProperty("version").GetString());
StringAssert.Contains(totalRoot.GetProperty("versions")[0].GetProperty("summary").GetString(), "随机放映室");
}
[TestMethod]
+456 -140
View File
@@ -1,7 +1,10 @@
using System.Runtime.InteropServices.WindowsRuntime;
using Microsoft.UI;
using Microsoft.UI.Text;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Media.Core;
@@ -9,10 +12,12 @@ using Windows.Media.Playback;
using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.Storage.Streams;
using Windows.System;
using WinRT.Interop;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Media;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
using YMhut.Box.WinUI.Services;
using YMhut.Box.WinUI.ViewModels.Tools;
@@ -25,8 +30,15 @@ public class RandomCinemaPage : ToolPageBase
private readonly AppPaths _appPaths = AppServices.GetRequiredService<AppPaths>();
private readonly IRemoteMediaCatalogService _catalogService = AppServices.GetRequiredService<IRemoteMediaCatalogService>();
private readonly IRemoteMediaResolver _mediaResolver = AppServices.GetRequiredService<IRemoteMediaResolver>();
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly AdaptiveToolViewModel _viewModel;
private readonly Grid _root = new();
private readonly ContentControl _bodyContent = new()
{
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Stretch
};
private readonly ScrollViewer _bodyScroll = new();
private readonly StackPanel _contentHost = new() { Spacing = 16 };
private readonly Grid _fullscreenOverlay = new() { Visibility = Visibility.Collapsed };
private readonly Grid _fullscreenStage = new();
@@ -38,6 +50,7 @@ public class RandomCinemaPage : ToolPageBase
IsActive = true,
Visibility = Visibility.Visible
};
private const int SourcePageSize = 8;
private RemoteMediaCatalog? _catalog;
private byte[]? _currentMediaBytes;
@@ -50,7 +63,12 @@ public class RandomCinemaPage : ToolPageBase
private RemoteMediaSource? _activeSource;
private UIElement? _activeMediaView;
private Panel? _activeMediaHost;
private MediaPlayer? _currentPlayer;
private Slider? _volumeSlider;
private TextBlock? _volumeText;
private bool _inlineFullscreen;
private bool _windowFullscreen;
private AppWindow? _appWindow;
public RandomCinemaPage(IToolModule module, AdaptiveToolViewModel viewModel, Action? goBack = null)
{
@@ -59,8 +77,19 @@ public class RandomCinemaPage : ToolPageBase
Background = ModernUi.AppBackground;
BindModule(module);
Content = BuildContent(module);
var exitFullscreen = new KeyboardAccelerator { Key = VirtualKey.Escape };
exitFullscreen.Invoked += (_, args) =>
{
if (_windowFullscreen || _inlineFullscreen)
{
ExitWindowFullscreen();
ExitInlineFullscreen();
args.Handled = true;
}
};
KeyboardAccelerators.Add(exitFullscreen);
Loaded += async (_, _) => await LoadRemoteConfigAsync();
Unloaded += (_, _) => DisposeCurrentImageStream();
Unloaded += (_, _) => DisposeCurrentMedia();
}
private UIElement BuildContent(IToolModule module)
@@ -72,24 +101,15 @@ public class RandomCinemaPage : ToolPageBase
Grid.SetRow(header, 0);
_root.Children.Add(header);
var bodyStack = new StackPanel
{
Spacing = 16,
HorizontalAlignment = HorizontalAlignment.Stretch,
Children = { _contentHost }
};
var body = new ScrollViewer
{
Margin = new Thickness(32, 0, 32, 32),
Padding = new Thickness(0, 0, 8, 0),
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
Content = bodyStack
};
body.SizeChanged += (_, e) => bodyStack.Width = Math.Max(280, e.NewSize.Width - 16);
Grid.SetRow(body, 1);
_root.Children.Add(body);
_bodyScroll.Margin = new Thickness(32, 0, 32, 32);
_bodyScroll.Padding = new Thickness(0, 0, 8, 0);
_bodyScroll.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
_bodyScroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
_bodyScroll.Content = _contentHost;
_bodyScroll.SizeChanged += (_, e) => _contentHost.Width = Math.Max(280, e.NewSize.Width - 16);
_bodyContent.Content = _bodyScroll;
Grid.SetRow(_bodyContent, 1);
_root.Children.Add(_bodyContent);
BuildFullscreenOverlay();
Grid.SetRowSpan(_fullscreenOverlay, 2);
@@ -127,33 +147,32 @@ public class RandomCinemaPage : ToolPageBase
_fullscreenOverlay.Children.Add(_fullscreenStage);
}
private Border BuildHeader(IToolModule module)
private Grid BuildHeader(IToolModule module)
{
var back = ModernUi.IconButton("\uE72B", AppLocalizer.T("返回工具箱", "Back to toolbox"), () => _goBack?.Invoke());
var refresh = ModernUi.PillButton(AppLocalizer.T("重新加载配置", "Reload sources"), "\uE895", async () => await LoadRemoteConfigAsync(forceRefresh: true), primary: true);
var grid = new Grid { ColumnSpacing = 14 };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var grid = new Grid
{
ColumnSpacing = 12,
Margin = new Thickness(32, 24, 32, 12)
};
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.Children.Add(back);
var icon = ModernUi.IconTile(module.Metadata.IconGlyph, 48, ModernUi.AccentSoft, ModernUi.Accent, 21);
Grid.SetColumn(icon, 1);
grid.Children.Add(icon);
var title = new StackPanel
{
Spacing = 4,
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
ModernUi.Text(ToolText.Name(module), 24, FontWeights.SemiBold, maxLines: 1),
ModernUi.Text(AppLocalizer.T("先选择随机图片或随机视频,再进入远程源加载媒体。", "Choose Random Images or Random Videos, then select a remote source."), 14, foreground: ModernUi.TextSecondary, maxLines: 2),
ModernUi.Text(ToolText.Name(module), 20, FontWeights.SemiBold, maxLines: 1),
_statusText
}
};
Grid.SetColumn(title, 2);
Grid.SetColumn(title, 1);
grid.Children.Add(title);
var actions = new StackPanel
@@ -163,13 +182,27 @@ public class RandomCinemaPage : ToolPageBase
VerticalAlignment = VerticalAlignment.Center,
Children = { _progress, refresh }
};
Grid.SetColumn(actions, 3);
Grid.SetColumn(actions, 2);
grid.Children.Add(actions);
return ModernUi.Card(grid, new Thickness(18), margin: new Thickness(32, 28, 32, 12), radius: 8);
return grid;
}
private void UseScrollableBody()
{
if (!ReferenceEquals(_bodyContent.Content, _bodyScroll))
{
_bodyContent.Content = _bodyScroll;
}
}
private void UseFixedBody(UIElement content)
{
_bodyContent.Content = content;
}
private async Task LoadRemoteConfigAsync(bool forceRefresh = false)
{
UseScrollableBody();
_viewModel.IsBusy = true;
_progress.IsActive = true;
_progress.Visibility = Visibility.Visible;
@@ -220,13 +253,16 @@ public class RandomCinemaPage : ToolPageBase
private void RenderChoiceCards()
{
UseScrollableBody();
_contentHost.Children.Clear();
var categories = _catalog?.EnabledCategories.ToList() ?? [];
var categories = _catalog?.EnabledCategories
.Where(IsSupportedCategory)
.ToList() ?? [];
if (categories.Count == 0)
{
_contentHost.Children.Add(BuildEmptyState(
AppLocalizer.T("没有用的媒体分类", "No enabled media categories"),
AppLocalizer.T("远程配置已读取,但没有可展示的分类。请刷新配置或稍后重试。", "The remote configuration loaded, but no categories are enabled. Reload sources or try again later.")));
AppLocalizer.T("没有用的图片或视频分类", "No image or video categories"),
AppLocalizer.T("随机放映室只显示图片和视频源。请刷新配置或稍后重试。", "Random Cinema only shows image and video sources. Reload sources or try again later.")));
return;
}
@@ -246,8 +282,9 @@ public class RandomCinemaPage : ToolPageBase
private Button BuildChoiceCard(RemoteMediaCategory category)
{
var total = category.Subcategories.Count;
var available = category.Subcategories.Count(source => source.IsAvailable);
var sources = DisplaySources(category);
var total = sources.Count;
var available = sources.Count(source => source.IsAvailable);
var enabled = total > 0;
var top = new Grid { ColumnSpacing = 12 };
top.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
@@ -297,13 +334,15 @@ public class RandomCinemaPage : ToolPageBase
return button;
}
private void RenderSourceCards(RemoteMediaCategory category)
private void RenderSourceCards(RemoteMediaCategory category, int page = 1)
{
UseScrollableBody();
_activeCategory = category;
_contentHost.Children.Clear();
_contentHost.Children.Add(BuildBreadcrumb(DisplayCategoryName(category), RenderChoiceCards));
if (category.Subcategories.Count == 0)
var sources = DisplaySources(category);
if (sources.Count == 0)
{
_contentHost.Children.Add(BuildEmptyState(
AppLocalizer.T("暂无资源源", "No media sources"),
@@ -311,18 +350,28 @@ public class RandomCinemaPage : ToolPageBase
return;
}
var totalPages = Math.Max(1, (int)Math.Ceiling(sources.Count / (double)SourcePageSize));
var currentPage = Math.Clamp(page, 1, totalPages);
var pageItems = sources
.Skip((currentPage - 1) * SourcePageSize)
.Take(SourcePageSize)
.ToArray();
var wrap = new VariableSizedWrapGrid
{
Orientation = Orientation.Horizontal,
ItemWidth = SourceCardWidth(category),
ItemHeight = SourceCardHeight(category)
};
foreach (var source in category.Subcategories)
foreach (var source in pageItems)
{
wrap.Children.Add(BuildSourceCard(category, source));
}
_contentHost.Children.Add(wrap);
if (totalPages > 1)
{
_contentHost.Children.Add(BuildPager(category, currentPage, totalPages));
}
}
private UIElement BuildBreadcrumb(string title, Action back)
@@ -428,6 +477,30 @@ public class RandomCinemaPage : ToolPageBase
}, new Thickness(24), radius: 8, background: ModernUi.SurfaceAlt);
}
private UIElement BuildPager(RemoteMediaCategory category, int page, int totalPages)
{
var previous = ModernUi.PillButton(AppLocalizer.T("上一页", "Previous"), "\uE76B", () => RenderSourceCards(category, page - 1));
previous.IsEnabled = page > 1;
previous.Opacity = page > 1 ? 1 : 0.5;
var next = ModernUi.PillButton(AppLocalizer.T("下一页", "Next"), "\uE76C", () => RenderSourceCards(category, page + 1));
next.IsEnabled = page < totalPages;
next.Opacity = page < totalPages ? 1 : 0.5;
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Center,
Children =
{
previous,
ModernUi.SmallBadge(AppLocalizer.T($"{page} / {totalPages} 页", $"Page {page} / {totalPages}"), ModernUi.TextSecondary, ModernUi.SurfaceAlt),
next
}
};
}
private static StackPanel BadgeRows(params UIElement[] badges)
{
var panel = new StackPanel { Spacing = 6 };
@@ -452,11 +525,10 @@ public class RandomCinemaPage : ToolPageBase
private static int CategorySortKey(RemoteMediaCategory category)
{
return category.Kind switch
return PrimaryKind(category) switch
{
RemoteMediaKind.Image => 0,
RemoteMediaKind.Video => 1,
RemoteMediaKind.Audio => 2,
_ => 3
};
}
@@ -484,23 +556,20 @@ public class RandomCinemaPage : ToolPageBase
return IsVideoCategory(category)
? "\uE714"
: IsAudioCategory(category)
? "\uE8D6"
: "\uEB9F";
: "\uEB9F";
}
private static string KindLabel(RemoteMediaCategory category)
{
return IsVideoCategory(category)
? AppLocalizer.T("视频", "Video")
: IsAudioCategory(category)
? AppLocalizer.T("音频", "Audio")
: AppLocalizer.T("图片", "Image");
: AppLocalizer.T("图片", "Image");
}
private static string CategoryDescription(RemoteMediaCategory category)
{
var sourceCount = AppLocalizer.T($"{category.Subcategories.Count} 个远程源", $"{category.Subcategories.Count} remote sources");
var count = DisplaySources(category).Count;
var sourceCount = AppLocalizer.T($"{count} 个远程源", $"{count} remote sources");
var playback = category.Layout.AutoPlay
? AppLocalizer.T("自动播放", "autoplay")
: AppLocalizer.T("手动播放", "manual play");
@@ -542,16 +611,22 @@ public class RandomCinemaPage : ToolPageBase
_activeSource = source;
ExitInlineFullscreen();
DisposeCurrentImageStream();
_currentPlayer?.Dispose();
_currentPlayer = null;
_volumeSlider = null;
_volumeText = null;
_currentMediaBytes = null;
_currentMediaUri = null;
_currentMediaCachePath = null;
_currentFileName = $"{source.Id}_{DateTime.Now:yyyyMMdd_HHmmss}";
_currentExtension = "." + (source.SupportedFormats.FirstOrDefault() ?? (IsVideoCategory(category) ? "mp4" : IsAudioCategory(category) ? "mp3" : "jpg")).TrimStart('.');
var mediaKind = MediaKind(category, source);
_currentExtension = "." + (source.SupportedFormats.FirstOrDefault() ?? (mediaKind == RemoteMediaKind.Video ? "mp4" : "jpg")).TrimStart('.');
_contentHost.Children.Clear();
_contentHost.Children.Add(BuildBreadcrumb($"{DisplayCategoryName(category)} / {DisplaySourceName(source)}", () => RenderSourceCards(category)));
var host = new Grid { MinHeight = 460 };
var host = new Grid
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
var loadingText = ModernUi.Text(AppLocalizer.T("正在解析媒体地址...", "Resolving media address..."), 14, foreground: ModernUi.TextSecondary);
var loadingProgress = new ProgressBar
{
@@ -582,16 +657,35 @@ public class RandomCinemaPage : ToolPageBase
saveButton.IsEnabled = source.Downloadable;
saveButton.Opacity = source.Downloadable ? 1 : 0.5;
actions.Children.Add(saveButton);
var volumeControl = BuildVolumeControl();
volumeControl.Visibility = mediaKind == RemoteMediaKind.Video ? Visibility.Visible : Visibility.Collapsed;
actions.Children.Add(volumeControl);
_contentHost.Children.Add(ModernUi.Card(new StackPanel
actions.HorizontalAlignment = HorizontalAlignment.Right;
actions.VerticalAlignment = VerticalAlignment.Center;
var toolbar = new Grid { ColumnSpacing = 12 };
toolbar.ColumnDefinitions.Add(new ColumnDefinition());
toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
toolbar.Children.Add(BuildBreadcrumb($"{DisplayCategoryName(category)} / {DisplaySourceName(source)}", () => RenderSourceCards(category)));
Grid.SetColumn(actions, 1);
toolbar.Children.Add(actions);
var panel = ModernUi.Card(new Grid
{
Spacing = 14,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition()
},
Children =
{
host,
actions
toolbar,
WithRow(host, 1)
}
}, new Thickness(14), radius: 8));
}, new Thickness(14), new Thickness(32, 0, 32, 24), radius: 8);
panel.VerticalAlignment = VerticalAlignment.Stretch;
UseFixedBody(panel);
try
{
@@ -602,11 +696,9 @@ public class RandomCinemaPage : ToolPageBase
? AppLocalizer.T("正在准备预览...", "Preparing preview...")
: AppLocalizer.T($"正在加载媒体... {value:0}%", $"Loading media... {value:0}%");
});
UIElement media = IsVideoCategory(category)
UIElement media = mediaKind == RemoteMediaKind.Video
? await BuildVideoViewerAsync(category, source, progress)
: IsAudioCategory(category)
? await BuildAudioViewerAsync(category, source, progress)
: await BuildImageViewerAsync(source, progress);
: await BuildImageViewerAsync(source, progress);
host.Children.Clear();
host.Children.Add(media);
_activeMediaHost = host;
@@ -635,9 +727,10 @@ public class RandomCinemaPage : ToolPageBase
}
}
private async Task<Image> BuildImageViewerAsync(RemoteMediaSource source, IProgress<double>? progress)
private async Task<UIElement> BuildImageViewerAsync(RemoteMediaSource source, IProgress<double>? progress)
{
var resolution = await _mediaResolver.ResolveMediaAsync(source.ApiUrl, RemoteMediaKind.Image, progress: progress);
var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Image, progress: progress);
EnsureExpectedMedia(resolution, RemoteMediaKind.Image);
_currentMediaUri = resolution.Uri;
_currentExtension = resolution.SuggestedExtension;
progress?.Report(45);
@@ -645,30 +738,32 @@ public class RandomCinemaPage : ToolPageBase
var bitmap = await BitmapFromBytesAsync(_currentMediaBytes);
_currentImageStream = bitmap.Stream;
progress?.Report(100);
return new Image
var image = new Image
{
MinHeight = 420,
Stretch = Stretch.Uniform,
Source = bitmap.Image,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
return MediaStage(image);
}
private async Task<UIElement> BuildVideoViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
{
var resolution = await _mediaResolver.ResolveMediaAsync(source.ApiUrl, RemoteMediaKind.Video, progress: progress);
var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Video, progress: progress);
EnsureExpectedMedia(resolution, RemoteMediaKind.Video);
_currentMediaUri = resolution.Uri;
_currentExtension = resolution.SuggestedExtension;
progress?.Report(90);
var media = new MediaPlayerElement
{
MinHeight = 420,
AreTransportControlsEnabled = true,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
var player = CreateRemoteMediaPlayer(resolution);
_currentPlayer = player;
ApplyVolumeToPlayer();
media.SetMediaPlayer(player);
AttachPlaybackFallback(media, player, resolution, category, source);
if (category.Layout.AutoPlay)
@@ -676,67 +771,122 @@ public class RandomCinemaPage : ToolPageBase
player.Play();
}
return WrapPlayableMedia(media, category, source);
}
private async Task<UIElement> BuildAudioViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
{
var resolution = await _mediaResolver.ResolveMediaAsync(source.ApiUrl, RemoteMediaKind.Audio, progress: progress);
_currentMediaUri = resolution.Uri;
_currentExtension = resolution.SuggestedExtension;
progress?.Report(90);
var media = new MediaPlayerElement
{
MinHeight = 88,
AreTransportControlsEnabled = true,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Center
};
var player = CreateRemoteMediaPlayer(resolution);
media.SetMediaPlayer(player);
AttachPlaybackFallback(media, player, resolution, category, source);
if (category.Layout.AutoPlay)
{
player.Play();
}
return new StackPanel
{
Spacing = 16,
HorizontalAlignment = HorizontalAlignment.Stretch,
Children =
{
new Border
{
Padding = new Thickness(18),
CornerRadius = new CornerRadius(8),
Background = ModernUi.SurfaceAlt,
Child = new StackPanel
{
Spacing = 12,
HorizontalAlignment = HorizontalAlignment.Center,
Children =
{
ModernUi.IconTile("\uE8D6", 72, ModernUi.AccentSoft, ModernUi.Accent, 30),
ModernUi.Text(DisplaySourceName(source), 18, FontWeights.SemiBold, maxLines: 1),
ModernUi.Text(AppLocalizer.T("音频已加载,使用下方控件播放、暂停和定位。", "Audio is loaded. Use the controls below to play, pause, and seek."), 13, foreground: ModernUi.TextSecondary, maxLines: 2)
}
}
},
WrapPlayableMedia(media, category, source)
}
};
return MediaStage(WrapPlayableMedia(media, category, source));
}
private static MediaPlayer CreateRemoteMediaPlayer(RemoteMediaResolution resolution)
{
return new MediaPlayer
{
Source = MediaSource.CreateFromUri(resolution.Uri),
Volume = 0.7
Source = MediaSource.CreateFromUri(resolution.Uri)
};
}
private static void EnsureExpectedMedia(RemoteMediaResolution resolution, RemoteMediaKind expectedKind)
{
if (!resolution.IsDirectMedia || !ResolutionMatchesExpectedKind(resolution, expectedKind))
{
throw new InvalidOperationException(expectedKind == RemoteMediaKind.Image
? AppLocalizer.T("远程图片源没有返回可识别的图片地址,请稍后重试或换一个图片源。", "The remote image source did not return a usable image.")
: AppLocalizer.T("远程视频源没有返回可播放的视频地址,请稍后重试或换一个视频源。", "The remote video source did not return a playable video."));
}
}
private static bool ResolutionMatchesExpectedKind(RemoteMediaResolution resolution, RemoteMediaKind expectedKind)
{
var contentType = (resolution.ContentType ?? string.Empty).Trim().ToLowerInvariant();
if (expectedKind == RemoteMediaKind.Image)
{
return contentType.StartsWith("image/", StringComparison.Ordinal) || IsImageFormat(resolution.SuggestedExtension);
}
if (expectedKind == RemoteMediaKind.Video)
{
return contentType.StartsWith("video/", StringComparison.Ordinal) || IsVideoFormat(resolution.SuggestedExtension);
}
return true;
}
private UIElement MediaStage(UIElement child)
{
var frame = new Viewbox
{
Stretch = Stretch.Uniform,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Child = new Border
{
Width = 960,
Height = 540,
CornerRadius = new CornerRadius(8),
Background = ModernUi.Brush("#FF0B0B0B"),
Clip = new Microsoft.UI.Xaml.Media.RectangleGeometry
{
Rect = new Windows.Foundation.Rect(0, 0, 960, 540)
},
Child = child
}
};
return new Grid
{
MinHeight = 320,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Background = ModernUi.SurfaceAlt,
Children = { frame }
};
}
private UIElement BuildVolumeControl()
{
_volumeText = ModernUi.Text(string.Empty, 12, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 1);
_volumeSlider = new Slider
{
Minimum = MediaVolumeModel.MinPercent,
Maximum = MediaVolumeModel.NormalMaxPercent,
Width = 130,
Value = Math.Clamp(_settingsService.Current.RandomCinemaVolumePercent, MediaVolumeModel.MinPercent, MediaVolumeModel.NormalMaxPercent),
VerticalAlignment = VerticalAlignment.Center
};
_volumeSlider.ValueChanged += async (_, _) =>
{
ApplyVolumeToPlayer();
var percent = _volumeSlider.Value;
await _settingsService.UpdateAsync(settings => settings.RandomCinemaVolumePercent = percent);
};
ApplyVolumeToPlayer();
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
new FontIcon { Glyph = "\uE767", FontSize = 15, Foreground = ModernUi.TextSecondary },
_volumeSlider,
_volumeText
}
};
}
private void ApplyVolumeToPlayer()
{
var value = _volumeSlider?.Value ?? _settingsService.Current.RandomCinemaVolumePercent;
var state = MediaVolumeModel.FromPercent(Math.Clamp(value, MediaVolumeModel.MinPercent, MediaVolumeModel.NormalMaxPercent));
if (_currentPlayer is not null)
{
_currentPlayer.Volume = state.PlatformVolume;
}
if (_volumeText is not null)
{
_volumeText.Text = MediaVolumeModel.FormatPercent(state.Percent, AppLocalizer.CurrentLanguage);
}
}
private Grid WrapPlayableMedia(MediaPlayerElement media, RemoteMediaCategory category, RemoteMediaSource source)
{
var status = PlaybackStatusOverlay();
@@ -798,7 +948,7 @@ public class RandomCinemaPage : ToolPageBase
_currentMediaBytes = bytes;
var file = await StorageFile.GetFileFromPathAsync(path);
player.Source = MediaSource.CreateFromStorageFile(file);
player.Volume = 0.7;
ApplyVolumeToPlayer();
SetPlaybackStatus(overlays, string.Empty, false);
if (category.Layout.AutoPlay)
{
@@ -934,9 +1084,42 @@ public class RandomCinemaPage : ToolPageBase
_inlineFullscreen = false;
}
private bool EnterWindowFullscreen()
{
_appWindow ??= GetCurrentAppWindow();
if (_appWindow is null)
{
return false;
}
_appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
_windowFullscreen = true;
return true;
}
private void ExitWindowFullscreen()
{
if (!_windowFullscreen)
{
return;
}
_appWindow ??= GetCurrentAppWindow();
_appWindow?.SetPresenter(AppWindowPresenterKind.Default);
_windowFullscreen = false;
}
private async Task ShowFullscreenAsync()
{
EnterInlineFullscreen();
if (EnterWindowFullscreen())
{
ToastService.Show(AppLocalizer.T("已进入全屏,按 Esc 或点击窗口控件可退出。", "Fullscreen enabled."), ToastKind.Info, TimeSpan.FromSeconds(2));
}
else
{
EnterInlineFullscreen();
}
await Task.CompletedTask;
}
@@ -1023,25 +1206,115 @@ public class RandomCinemaPage : ToolPageBase
}
}
private void DisposeCurrentMedia()
{
ExitWindowFullscreen();
ExitInlineFullscreen();
DisposeCurrentImageStream();
try
{
_currentPlayer?.Dispose();
}
catch (Exception exception)
{
CrashLog.Write(exception);
}
finally
{
_currentPlayer = null;
}
}
private sealed record BitmapStreamResult(BitmapImage Image, InMemoryRandomAccessStream Stream);
private static bool IsSupportedCategory(RemoteMediaCategory category)
{
return PrimaryKind(category) is RemoteMediaKind.Image or RemoteMediaKind.Video ||
DisplaySources(category).Count > 0;
}
private static IReadOnlyList<RemoteMediaSource> DisplaySources(RemoteMediaCategory category)
{
return category.Subcategories
.Where(source => MediaKind(category, source) is RemoteMediaKind.Image or RemoteMediaKind.Video)
.ToArray();
}
private static RemoteMediaKind PrimaryKind(RemoteMediaCategory category)
{
if (category.Kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
{
return category.Kind;
}
if (category.Id.Contains("video", StringComparison.OrdinalIgnoreCase) ||
category.Id.Contains("movie", StringComparison.OrdinalIgnoreCase))
{
return RemoteMediaKind.Video;
}
if (category.Id.Contains("image", StringComparison.OrdinalIgnoreCase) ||
category.Id.Contains("img", StringComparison.OrdinalIgnoreCase) ||
category.Id.Contains("pic", StringComparison.OrdinalIgnoreCase))
{
return RemoteMediaKind.Image;
}
var sourceKinds = category.Subcategories
.Select(source => MediaKind(category, source))
.Where(kind => kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
.Distinct()
.ToArray();
return sourceKinds.Length == 1 ? sourceKinds[0] : RemoteMediaKind.Unknown;
}
private static RemoteMediaKind MediaKind(RemoteMediaCategory category, RemoteMediaSource source)
{
if (source.Kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
{
return source.Kind;
}
if (category.Kind is RemoteMediaKind.Image or RemoteMediaKind.Video)
{
return category.Kind;
}
if (!string.IsNullOrWhiteSpace(source.MediaType))
{
var mediaType = source.MediaType.Trim().ToLowerInvariant();
if (mediaType.Contains("video", StringComparison.Ordinal) || mediaType.Contains("mp4", StringComparison.Ordinal))
{
return RemoteMediaKind.Video;
}
if (mediaType.Contains("image", StringComparison.Ordinal) || mediaType.Contains("img", StringComparison.Ordinal))
{
return RemoteMediaKind.Image;
}
}
if (source.SupportedFormats.Any(IsVideoFormat))
{
return RemoteMediaKind.Video;
}
if (source.SupportedFormats.Any(IsImageFormat))
{
return RemoteMediaKind.Image;
}
return RemoteMediaKind.Unknown;
}
private static bool IsVideoCategory(RemoteMediaCategory category)
{
return category.Kind == RemoteMediaKind.Video ||
category.Id.Contains("video", StringComparison.OrdinalIgnoreCase) ||
category.Subcategories.SelectMany(source => source.SupportedFormats).Any(IsVideoFormat);
}
private static bool IsAudioCategory(RemoteMediaCategory category)
{
return category.Kind == RemoteMediaKind.Audio ||
category.Id.Contains("audio", StringComparison.OrdinalIgnoreCase) ||
category.Subcategories.SelectMany(source => source.SupportedFormats).Any(IsAudioFormat);
return PrimaryKind(category) == RemoteMediaKind.Video;
}
private static bool IsImageCategory(RemoteMediaCategory category)
{
return !IsVideoCategory(category) && !IsAudioCategory(category);
return PrimaryKind(category) == RemoteMediaKind.Image;
}
private static bool IsVideoFormat(string format)
@@ -1050,24 +1323,34 @@ public class RandomCinemaPage : ToolPageBase
return ext is "mp4" or "mkv" or "webm" or "avi" or "mov" or "wmv" or "m4v";
}
private static bool IsAudioFormat(string format)
private static bool IsImageFormat(string format)
{
var ext = format.Trim().TrimStart('.').ToLowerInvariant();
return ext is "mp3" or "wav" or "flac" or "aac" or "m4a" or "ogg" or "wma";
return ext is "jpg" or "jpeg" or "png" or "webp" or "gif" or "bmp" or "tif" or "tiff";
}
private static string DisplayCategoryName(RemoteMediaCategory category)
{
if (!string.IsNullOrWhiteSpace(category.DisplayName))
if (IsVideoCategory(category))
{
return AppLocalizer.T("随机视频", "Random Videos");
}
if (IsImageCategory(category))
{
return AppLocalizer.T("随机图片", "Random Images");
}
if (!string.IsNullOrWhiteSpace(category.DisplayName) &&
!string.Equals(category.DisplayName, "image", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(category.DisplayName, "video", StringComparison.OrdinalIgnoreCase))
{
return category.DisplayName;
}
return IsVideoCategory(category)
? AppLocalizer.T("随机视频", "Random Videos")
: IsAudioCategory(category)
? AppLocalizer.T("随机音频", "Random Audio")
: AppLocalizer.T("随机图片", "Random Images");
: AppLocalizer.T("随机图片", "Random Images");
}
private static string DisplaySourceName(RemoteMediaSource source)
@@ -1126,7 +1409,22 @@ public class RandomCinemaPage : ToolPageBase
private static string FriendlyError(string message)
{
return AppLocalizer.SanitizeSensitiveText(message, 120);
var normalized = message ?? string.Empty;
if (normalized.Contains("SSL", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("certificate", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("connection could not be established", StringComparison.OrdinalIgnoreCase))
{
return AppLocalizer.T("远程源连接失败,可能是证书、网络或源站临时不可用。请稍后重试或换一个媒体源。", "The remote source is unavailable. Try again later or choose another source.");
}
if (normalized.Contains("unsupported", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("file path", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("invalid", StringComparison.OrdinalIgnoreCase))
{
return AppLocalizer.T("远程源返回的内容不是当前类型可用的媒体文件,请换一个媒体源。", "The remote source did not return a usable media file.");
}
return AppLocalizer.SanitizeSensitiveText(normalized, 120);
}
private static T WithColumn<T>(T element, int column) where T : FrameworkElement
@@ -1134,4 +1432,22 @@ public class RandomCinemaPage : ToolPageBase
Grid.SetColumn(element, column);
return element;
}
private static T WithRow<T>(T element, int row) where T : FrameworkElement
{
Grid.SetRow(element, row);
return element;
}
private static AppWindow? GetCurrentAppWindow()
{
var window = App.CurrentWindow;
if (window is null)
{
return null;
}
var windowId = Win32Interop.GetWindowIdFromWindow(WindowNative.GetWindowHandle(window));
return AppWindow.GetFromWindowId(windowId);
}
}