更新 update 门户站点界面和后台功能
build-winui / winui (push) Has been cancelled
build-winui / winui (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user