feat(PostIt): add dedicated blog reader and video playback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
40eb5086c1
commit
c1254a522f
18 changed files with 827 additions and 20 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -4,6 +4,18 @@
|
|||
|
||||
### Added
|
||||
|
||||
* [PostIt] Vue de lecture des billets par défaut, avec titre, auteur,
|
||||
date et contenu Markdown défilant ; l'édition et la création restent
|
||||
accessibles par des actions explicites, sans perdre le brouillon en
|
||||
passant de la lecture à l'édition.
|
||||
* [PostIt] Résolution des liens et images relatifs depuis
|
||||
`<autorité Yavsc.Org>/files/<login auteur>/`, reconnaissance des fichiers
|
||||
utilisateur sur cette origine et détection du type MIME par HTTP.
|
||||
* [PostIt] Page vidéo dédiée utilisant LibVLCSharp.Avalonia sur desktop,
|
||||
avec lecture/pause, libération du lecteur à la fermeture et accès au
|
||||
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
|
||||
les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système.
|
||||
|
||||
### Changed
|
||||
|
||||
* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,49 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop).
|
|||
Toute la logique (VM, services, navigation, settings, OIDC) est
|
||||
dans le code-base partagé.
|
||||
|
||||
## Lecture des billets et fichiers utilisateur
|
||||
|
||||
La page des blogs affiche par défaut une vue de lecture : titre, login
|
||||
de l'auteur, date de modification et contenu Markdown dans une zone
|
||||
défilante de largeur limitée. **Modifier** ouvre l'éditeur existant et
|
||||
**Nouveau billet** initialise explicitement un brouillon. **Lire** affiche
|
||||
le contenu enregistré sans effacer les modifications non enregistrées ;
|
||||
un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé.
|
||||
|
||||
La base des ressources est dérivée de `Settings.Authentication.Authority`
|
||||
(le déploiement Yavsc.Org), jamais de `BlogsApiUrl` :
|
||||
`https://<hôte Org>/files/<Author.UserName>/`. Le repli sur `AuthorId`
|
||||
conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé.
|
||||
Un lien `photo.jpg` ou `blogs/42/video.mp4` est relatif à ce répertoire ;
|
||||
un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org.
|
||||
Les liens absolus ne sont pas réécrits. Les images Markdown ``
|
||||
sont affichées dans le billet et leur résolveur n'accepte que HTTP(S),
|
||||
sans accès aux fichiers locaux du poste.
|
||||
|
||||
Le lecteur utilise `Markdown.Avalonia.Tight` 12.0.0-a3 (version compatible
|
||||
Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur
|
||||
conserve `MarkdownViewer.Core` : son gestionnaire intégré ouvre le navigateur
|
||||
avant d'émettre l'événement de lien et ne permet donc pas ce routage.
|
||||
|
||||
Au clic sur un fichier sous `/files/<login>/...` de la même origine Org
|
||||
(schéma, hôte et port), `BlogResourceService` sonde `Content-Type` avec
|
||||
`HEAD`, puis un `GET` limité par `Range: bytes=0-0` si le serveur refuse
|
||||
`HEAD` (405/501). Le corps n'est pas téléchargé pour la détection. Une
|
||||
extension connue sert de repli uniquement en l'absence de type exploitable
|
||||
(type absent ou `application/octet-stream`). Les erreurs HTTP/réseau sont
|
||||
affichées dans la barre de statut, sans les masquer par une déduction MIME.
|
||||
|
||||
Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop.
|
||||
Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et
|
||||
une action d'ouverture externe. Le lecteur et ses ressources natives sont
|
||||
arrêtés et libérés à la sortie de la page. **LibVLC doit être installé**
|
||||
sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les
|
||||
bibliothèques natives adaptées sont nécessaires sous Windows/macOS).
|
||||
Une absence de LibVLC ou une erreur de décodage est signalée dans la page.
|
||||
Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible,
|
||||
les vidéos sont ouvertes dans le navigateur, comme les autres fichiers
|
||||
(images liées, PDF, téléchargements) et les liens externes.
|
||||
|
||||
## ViewLocator custo
|
||||
|
||||
Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
|
||||
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
|
|
|
|||
201
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
201
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System.Net;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Markdown.Avalonia;
|
||||
using ColorTextBlock.Avalonia;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class BlogReaderTests
|
||||
{
|
||||
private static BlogPostDto Post(string article = "Texte du billet.") => new()
|
||||
{
|
||||
Id = 42,
|
||||
Title = "Un billet à lire",
|
||||
Article = article,
|
||||
AuthorId = "user-id-not-login",
|
||||
Author = new BlogPostAuthorDto { UserName = "paul" },
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null)
|
||||
=> new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"),
|
||||
new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } },
|
||||
resources: resources);
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
Assert.False(vm.IsEditing);
|
||||
Assert.Equal("paul", vm.ReaderAuthor);
|
||||
Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl);
|
||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reading_does_not_publish_or_discard_unsaved_editor_changes()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
vm.EditPostCommand.Execute(null);
|
||||
vm.DraftArticle = "Modifications non enregistrées";
|
||||
vm.ReadPostCommand.Execute(null);
|
||||
Assert.False(vm.IsEditing);
|
||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||
Assert.True(vm.IsDraftModified);
|
||||
vm.EditPostCommand.Execute(null);
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_post_is_an_explicit_action_and_protects_unsaved_changes()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
vm.NewPostCommand.Execute(null);
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.Null(vm.SelectedPost);
|
||||
Assert.Empty(vm.DraftTitle);
|
||||
Assert.Empty(vm.DraftArticle!);
|
||||
vm.DraftTitle = "Nouveau brouillon";
|
||||
vm.NewPostCommand.Execute(null);
|
||||
Assert.Equal("Nouveau brouillon", vm.DraftTitle);
|
||||
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedPost = Post("[Vidéo](clip.mp4)");
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||
Assert.False(page.SaveButton.IsVisible);
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
Assert.Equal(vm.ReaderArticle, reader.Markdown);
|
||||
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
|
||||
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
|
||||
vm.EditPostCommand.Execute(null);
|
||||
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||
Assert.True(page.SaveButton.IsVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||
Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height);
|
||||
Assert.True(scroll.Extent.Height > scroll.Viewport.Height);
|
||||
scroll.ScrollToEnd();
|
||||
Assert.True(scroll.Offset.Y > 0);
|
||||
vm.SelectedPost = null;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Empty(reader.Markdown!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
|
||||
{
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
||||
{
|
||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType = new("video/mp4");
|
||||
return response;
|
||||
}));
|
||||
var vm = CreateViewModel(new BlogResourceService(client));
|
||||
vm.SelectedPost = Post();
|
||||
var app = (App)Application.Current!;
|
||||
var root = new MainView();
|
||||
app.AttachMainWindow(root);
|
||||
var template = new FuncDataTemplate<VideoPageViewModel>((_, _) => new ContentPage());
|
||||
app.DataTemplates.Insert(0, template);
|
||||
try
|
||||
{
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
||||
var link = Assert.IsType<CHyperlink>(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)")));
|
||||
Assert.NotNull(link.Command);
|
||||
link.Command(link.CommandParameter!);
|
||||
Assert.NotNull(vm.OpenResourceCommand.ExecutionTask);
|
||||
await vm.OpenResourceCommand.ExecutionTask;
|
||||
var target = Assert.IsType<VideoPageViewModel>(root.NavRoot.NavigationStack.Last().DataContext);
|
||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri);
|
||||
Assert.Equal("video/mp4", target.MimeType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
app.DataTemplates.Remove(template);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
|
||||
{
|
||||
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
|
||||
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
|
||||
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Broken_resource_reports_an_error_and_releases_busy_state()
|
||||
{
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound)));
|
||||
var vm = CreateViewModel(new BlogResourceService(client));
|
||||
vm.SelectedPost = Post();
|
||||
await vm.OpenResourceCommand.ExecuteAsync("absent.mp4");
|
||||
Assert.False(vm.IsBusy);
|
||||
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
|
||||
}
|
||||
}
|
||||
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class BlogResourceServiceTests
|
||||
{
|
||||
private const string Authority = "https://yavsc.pschneider.fr";
|
||||
|
||||
[Theory]
|
||||
[InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")]
|
||||
[InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")]
|
||||
[InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")]
|
||||
[InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||
[InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")]
|
||||
[InlineData("//elsewhere.test/page", "https://elsewhere.test/page")]
|
||||
public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected)
|
||||
=> Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri);
|
||||
|
||||
[Fact]
|
||||
public void Encodes_login_and_keeps_trailing_slash()
|
||||
=> Assert.Equal(Authority + "/files/paul%20test/",
|
||||
BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri);
|
||||
|
||||
[Theory]
|
||||
[InlineData(Authority + "/files/paul/movie.mp4", true)]
|
||||
[InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)]
|
||||
[InlineData(Authority + "/files/paul/", false)]
|
||||
[InlineData(Authority + "/files/paul", false)]
|
||||
[InlineData(Authority + "/files/", false)]
|
||||
[InlineData(Authority + "/files-other/paul/movie.mp4", false)]
|
||||
[InlineData(Authority + "/page.mp4", false)]
|
||||
[InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)]
|
||||
[InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected)
|
||||
=> Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority));
|
||||
|
||||
[Theory]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("file:///tmp/video.mp4")]
|
||||
[InlineData("data:video/mp4;base64,AAAA")]
|
||||
public void Rejects_non_web_links(string link)
|
||||
=> Assert.Throws<InvalidOperationException>(() => BlogResourceService.Resolve(link, Authority, "paul"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("video/webm", "movie.jpg", "video/webm")]
|
||||
[InlineData("image/jpeg", "movie.mp4", "image/jpeg")]
|
||||
[InlineData("application/octet-stream", "movie.MP4", "video/mp4")]
|
||||
[InlineData(null, "movie.webm", "video/webm")]
|
||||
[InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")]
|
||||
[InlineData(null, "file.unknown", "application/octet-stream")]
|
||||
public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Head, request.Method);
|
||||
return Response(HttpStatusCode.OK, mime);
|
||||
}));
|
||||
var service = new BlogResourceService(client);
|
||||
Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.MethodNotAllowed)]
|
||||
[InlineData(HttpStatusCode.NotImplemented)]
|
||||
public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status)
|
||||
{
|
||||
var calls = 0;
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
calls++;
|
||||
if (request.Method == HttpMethod.Head)
|
||||
return Response(status, null);
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal("bytes=0-0", request.Headers.Range?.ToString());
|
||||
return Response(HttpStatusCode.PartialContent, "video/mp4");
|
||||
}));
|
||||
Assert.Equal("video/mp4", await new BlogResourceService(client)
|
||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(_ => Response(status, null)));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => new BlogResourceService(client)
|
||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||
[InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||
[InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||
public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
Assert.Equal(expected, request.RequestUri!.AbsoluteUri);
|
||||
return Response(HttpStatusCode.OK, "image/jpeg");
|
||||
}));
|
||||
var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" };
|
||||
await using var stream = await resolver.ResolveImageResource(link);
|
||||
Assert.NotNull(stream);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
[InlineData("data:image/png;base64,AAAA")]
|
||||
public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link)
|
||||
{
|
||||
var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" };
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveImageResource(link));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode status, string? mime)
|
||||
{
|
||||
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) };
|
||||
if (mime is not null)
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
||||
return response;
|
||||
}
|
||||
|
||||
internal sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ public class MainPageSaveTests
|
|||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var viewModel = new BlogsViewModel(blog);
|
||||
viewModel.NewPostCommand.Execute(null);
|
||||
|
||||
var page = new BlogsPage { DataContext = viewModel };
|
||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public class MarkdownEditorScrollTests
|
|||
[InlineData(true)]
|
||||
public void Blog_page_bounds_the_article_viewport(bool preview)
|
||||
{
|
||||
var page = new BlogsPage();
|
||||
var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } };
|
||||
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
|
||||
editor.MarkdownText = LongArticle;
|
||||
editor.IsPreviewVisible = preview;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddTransient<MainWindow>();
|
||||
services.AddTransient<MainView>();
|
||||
services.AddTransient<BlogsPage>();
|
||||
services.AddTransient<VideoPage>();
|
||||
|
||||
|
||||
// Models
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" />
|
||||
<PackageReference Include="MarkdownViewer.Core" />
|
||||
<PackageReference Include="Markdown.Avalonia.Tight" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Mono.Options" />
|
||||
|
|
|
|||
33
src/PostIt/PostIt/Services/BlogImageResolver.cs
Normal file
33
src/PostIt/PostIt/Services/BlogImageResolver.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Markdown.Avalonia.Utils;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class BlogImageResolver : IPathResolver
|
||||
{
|
||||
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public BlogImageResolver() : this(Client) { }
|
||||
|
||||
public BlogImageResolver(HttpClient client) => _client = client;
|
||||
|
||||
public string? AssetPathRoot { get; set; }
|
||||
public IEnumerable<string>? CallerAssemblyNames { get; set; }
|
||||
|
||||
public async Task<Stream?> ResolveImageResource(string relativeOrAbsolutePath)
|
||||
{
|
||||
var uri = !relativeOrAbsolutePath.StartsWith("/", StringComparison.Ordinal)
|
||||
&& Uri.TryCreate(relativeOrAbsolutePath, UriKind.Absolute, out var absolute)
|
||||
? absolute
|
||||
: new Uri(new Uri(AssetPathRoot ?? throw new InvalidOperationException(
|
||||
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
|
||||
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS.");
|
||||
return await _client.GetStreamAsync(uri);
|
||||
}
|
||||
}
|
||||
98
src/PostIt/PostIt/Services/BlogResourceService.cs
Normal file
98
src/PostIt/PostIt/Services/BlogResourceService.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Abstract.Files;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class BlogResourceService
|
||||
{
|
||||
private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public BlogResourceService() : this(SharedClient) { }
|
||||
|
||||
public BlogResourceService(HttpClient client) => _client = client;
|
||||
|
||||
public static Uri GetUserBaseUri(string authority, string login)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
throw new InvalidOperationException("Le login de l'auteur est nécessaire pour résoudre ses fichiers.");
|
||||
return FileServerUrlHelpers.GetUserFilesUri(GetAuthority(authority), Uri.EscapeDataString(login) + "/");
|
||||
}
|
||||
|
||||
public static Uri Resolve(string link, string authority, string login)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(link))
|
||||
throw new ArgumentException("Le lien est vide.", nameof(link));
|
||||
var origin = GetAuthority(authority);
|
||||
var uri = link.StartsWith("/", StringComparison.Ordinal)
|
||||
? new Uri(origin, link)
|
||||
: Uri.TryCreate(link, UriKind.Absolute, out var absolute)
|
||||
? absolute
|
||||
: new Uri(GetUserBaseUri(authority, login), link);
|
||||
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||
throw new InvalidOperationException("Seuls les liens HTTP et HTTPS sont pris en charge.");
|
||||
return uri;
|
||||
}
|
||||
|
||||
public static bool IsUserResource(Uri uri, string authority)
|
||||
{
|
||||
var root = FileServerUrlHelpers.GetUserFilesBaseUri(GetAuthority(authority));
|
||||
if (!root.IsBaseOf(uri) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||
return false;
|
||||
var path = uri.AbsolutePath[root.AbsolutePath.Length..];
|
||||
var separator = path.IndexOf('/');
|
||||
return separator > 0 && separator < path.Length - 1;
|
||||
}
|
||||
|
||||
public async Task<string> GetMimeTypeAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
using var response = await _client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)
|
||||
{
|
||||
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
|
||||
using var fallback = await _client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
fallback.EnsureSuccessStatusCode();
|
||||
return GetMimeType(fallback, uri);
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
return GetMimeType(response, uri);
|
||||
}
|
||||
|
||||
private static string GetMimeType(HttpResponseMessage response, Uri uri)
|
||||
{
|
||||
var mime = response.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrEmpty(mime) && !mime.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
|
||||
return mime;
|
||||
return System.IO.Path.GetExtension(uri.AbsolutePath).ToLowerInvariant() switch
|
||||
{
|
||||
".mp4" or ".m4v" => "video/mp4",
|
||||
".webm" => "video/webm",
|
||||
".ogv" => "video/ogg",
|
||||
".ogg" => "audio/ogg",
|
||||
".mov" => "video/quicktime",
|
||||
".mkv" => "video/x-matroska",
|
||||
".avi" => "video/x-msvideo",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".pdf" => "application/pdf",
|
||||
_ => mime ?? "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private static Uri GetAuthority(string authority)
|
||||
{
|
||||
if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||
|| !string.IsNullOrEmpty(uri.UserInfo))
|
||||
throw new InvalidOperationException("L'URL de Yavsc.Org est invalide dans les paramètres d'authentification.");
|
||||
return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ public class ViewLocator : IDataTemplate
|
|||
return data switch
|
||||
{
|
||||
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
||||
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
|
||||
Settings => services.GetRequiredService<SettingsPage>(),
|
||||
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
|
||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ using Yavsc.Api.Client;
|
|||
using Yavsc.Abstract.Files;
|
||||
using Yavsc.Server.Helpers;
|
||||
using AvaloniaEdit.Document;
|
||||
using Avalonia.Controls;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -141,8 +143,85 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ReaderArticle))]
|
||||
[NotifyPropertyChangedFor(nameof(ReaderAuthor))]
|
||||
public partial BlogPostDto? SelectedPost { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsEditing { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? ReaderBaseUrl { get; private set; }
|
||||
|
||||
public string ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty;
|
||||
public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? string.Empty;
|
||||
|
||||
private readonly BlogResourceService _resources = new();
|
||||
|
||||
[RelayCommand]
|
||||
private void NewPost()
|
||||
{
|
||||
if (IsDraftModified)
|
||||
{
|
||||
this.SetWarningStatus("Enregistrez vos modifications avant de créer un autre billet.");
|
||||
return;
|
||||
}
|
||||
SelectedPost = null;
|
||||
DraftTitle = string.Empty;
|
||||
DraftArticle = string.Empty;
|
||||
DraftAttachments.Clear();
|
||||
_pendingAttachmentLinks.Clear();
|
||||
DraftIsPublished = false;
|
||||
MarkDraftSaved();
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void EditPost() => IsEditing = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void ReadPost()
|
||||
{
|
||||
IsEditing = false;
|
||||
UpdateReader();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenResourceAsync(string? link)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsBusy = true;
|
||||
var uri = BlogResourceService.Resolve(link ?? string.Empty,
|
||||
Settings.Authentication.Authority, ReaderAuthor);
|
||||
if (BlogResourceService.IsUserResource(uri, Settings.Authentication.Authority))
|
||||
{
|
||||
this.SetInfoStatus("Détection du type de fichier...");
|
||||
var mime = await _resources.GetMimeTypeAsync(uri);
|
||||
if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !OperatingSystem.IsBrowser() && !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS())
|
||||
{
|
||||
await ((App)Application.Current!).PushPageAsync(new VideoPageViewModel(uri, mime));
|
||||
this.SetInfoStatus($"Vidéo : {mime}");
|
||||
return;
|
||||
}
|
||||
this.SetInfoStatus($"Ouverture dans le navigateur ({mime}).");
|
||||
}
|
||||
var topLevel = TopLevel.GetTopLevel(((App)Application.Current!).View!)
|
||||
?? throw new InvalidOperationException("La fenêtre de l'application est indisponible.");
|
||||
if (!await topLevel.Launcher.LaunchUriAsync(uri))
|
||||
throw new InvalidOperationException("Le système n'a pas pu ouvrir ce lien.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Impossible d'ouvrir le lien : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
|
|
@ -258,6 +337,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
|
||||
await RefreshPostsAsync();
|
||||
UpdateReader();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -503,12 +583,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||
/// (Settings, BlogApiClient) overload below.
|
||||
/// </summary>
|
||||
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
|
||||
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null,
|
||||
BlogResourceService? resources = null)
|
||||
{
|
||||
WindowTitle = "Blog";
|
||||
SettingsModel = new Settings();
|
||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
||||
Services = services;
|
||||
_resources = resources ?? new BlogResourceService();
|
||||
Init(settings);
|
||||
}
|
||||
|
||||
|
|
@ -523,6 +605,9 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|
||||
partial void OnSelectedPostChanged(BlogPostDto? value)
|
||||
{
|
||||
UpdateReader();
|
||||
if (!_preserveDraft)
|
||||
IsEditing = false;
|
||||
if (_preserveDraft) return;
|
||||
// Mirror the selection into the editor buffer so the
|
||||
// XAML-bound TextBox/TextEditor show the right content
|
||||
|
|
@ -542,6 +627,25 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
UpdateCommandStates();
|
||||
}
|
||||
|
||||
private void UpdateReader()
|
||||
{
|
||||
string? baseUrl = null;
|
||||
try
|
||||
{
|
||||
if (SelectedPost is not null)
|
||||
baseUrl = BlogResourceService.GetUserBaseUri(
|
||||
Settings.Authentication.Authority, ReaderAuthor).AbsoluteUri;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or UriFormatException)
|
||||
{
|
||||
this.SetErrorStatus($"Impossible de résoudre les ressources du billet : {ex.Message}");
|
||||
}
|
||||
ReaderBaseUrl = baseUrl;
|
||||
OnPropertyChanged(nameof(SelectedPost));
|
||||
OnPropertyChanged(nameof(ReaderArticle));
|
||||
OnPropertyChanged(nameof(ReaderAuthor));
|
||||
}
|
||||
|
||||
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
|
||||
|
||||
// Save's CanExecute depends on the buffer: the button must
|
||||
|
|
|
|||
12
src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
Normal file
12
src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using System;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
|
||||
{
|
||||
public Uri Source { get; } = source;
|
||||
public string MimeType { get; } = mimeType;
|
||||
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
|
||||
public override bool CanNavigateNext { get => false; protected set { } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { } }
|
||||
}
|
||||
|
|
@ -2,13 +2,12 @@
|
|||
x:Class="PostIt.Views.Blogs.BlogsPage"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||
xmlns:reader="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||
xmlns:services="using:PostIt.Services"
|
||||
xmlns:models="using:Yavsc.Blogspot"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
xmlns:views="using:PostIt.Views.Blogs"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
|
|
@ -25,7 +24,7 @@
|
|||
</Style>
|
||||
</ContentPage.Styles>
|
||||
<Grid
|
||||
RowDefinitions="Auto,Auto,*"
|
||||
RowDefinitions="Auto,Auto,*,Auto"
|
||||
Margin="5,5,10,5"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
|
|
@ -36,23 +35,30 @@
|
|||
<TextBlock
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Text="Blog Posts" />
|
||||
Text="Billets de blog" />
|
||||
<TextBox
|
||||
HorizontalAlignment="Right"
|
||||
PlaceholderText="Filter by title or author..."
|
||||
PlaceholderText="Rechercher un titre ou un auteur..."
|
||||
Text="{Binding SearchText, Mode=TwoWay}" />
|
||||
</WrapPanel>
|
||||
<CommandBar x:Name="BlogsCommandBar"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
|
||||
<CommandBar.PrimaryCommands>
|
||||
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
|
||||
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource RefreshIcon}" />
|
||||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
|
||||
<CommandBarButton x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
|
||||
IsVisible="{Binding IsEditing}" Label="Lire" />
|
||||
<CommandBarButton x:Name="EditPostButton" Command="{Binding EditPostCommand}"
|
||||
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
IsVisible="{Binding !IsEditing}" Label="Modifier" />
|
||||
<CommandBarButton
|
||||
x:Name="SaveButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding SaveCommand}"
|
||||
Classes.modified="{Binding IsDraftModified}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
|
|
@ -61,13 +67,14 @@
|
|||
<PathIcon Data="{StaticResource SaveIcon}" />
|
||||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
|
||||
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource DeleteIcon}" />
|
||||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
x:Name="AddAttachmentButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Click="AddAttachment_Click"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Label="Ajouter fichiers">
|
||||
|
|
@ -77,6 +84,7 @@
|
|||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
x:Name="ManageAclButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding ManageAclAsync}"
|
||||
Label="ACL">
|
||||
<CommandBarButton.Icon>
|
||||
|
|
@ -92,6 +100,7 @@
|
|||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarToggleButton
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding TogglePublishCommand}"
|
||||
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
||||
Label="Publié">
|
||||
|
|
@ -99,13 +108,14 @@
|
|||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
||||
<CommandBarToggleButton IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
||||
<CommandBarToggleButton.Icon>
|
||||
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarButton
|
||||
x:Name="OpenSignatureDevButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding OpenSignatureDevAsync}"
|
||||
Label="[DEV] Signature">
|
||||
<CommandBarButton.Icon>
|
||||
|
|
@ -173,7 +183,8 @@
|
|||
HorizontalAlignment="Stretch"
|
||||
ResizeDirection="Rows" />
|
||||
|
||||
<Grid
|
||||
<Grid x:Name="PostEditor"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Grid.Row="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
|
|
@ -183,7 +194,6 @@
|
|||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
|
|
@ -226,9 +236,37 @@
|
|||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||
|
||||
</Grid>
|
||||
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
|
||||
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
|
||||
<TextBlock Text="{Binding SelectedPost.Title}" FontSize="28" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" />
|
||||
<WrapPanel Grid.Row="1" Orientation="Horizontal"
|
||||
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
|
||||
<TextBlock Text="{Binding SelectedPost.DateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
|
||||
Opacity="0.65" />
|
||||
</WrapPanel>
|
||||
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
|
||||
AssetPathRoot="{Binding ReaderBaseUrl}"
|
||||
Markdown="{Binding ReaderArticle}"
|
||||
SaveScrollValueWhenContentUpdated="False">
|
||||
<reader:MarkdownScrollViewer.Plugins>
|
||||
<reader:MdAvPlugins>
|
||||
<reader:MdAvPlugins.PathResolver>
|
||||
<services:BlogImageResolver />
|
||||
</reader:MdAvPlugins.PathResolver>
|
||||
</reader:MdAvPlugins>
|
||||
</reader:MarkdownScrollViewer.Plugins>
|
||||
<reader:MarkdownScrollViewer.Engine>
|
||||
<reader:Markdown HyperlinkCommand="{Binding ((vm:BlogsViewModel)DataContext).OpenResourceCommand, ElementName=ArticleReader}" />
|
||||
</reader:MarkdownScrollViewer.Engine>
|
||||
</reader:MarkdownScrollViewer>
|
||||
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
Text="Choisissez un billet dans la liste pour le lire."
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -14,15 +14,12 @@ public partial class BlogsPage : ContentPage
|
|||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
if (DataContext is ViewModels.BlogsViewModel vm)
|
||||
{
|
||||
if (!vm.IsLoaded)
|
||||
{
|
||||
vm.RefreshAsync().Wait();
|
||||
}
|
||||
await vm.InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
19
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
Normal file
19
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<ContentPage
|
||||
x:Class="PostIt.Views.Blogs.VideoPage"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
x:DataType="vm:VideoPageViewModel">
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
|
||||
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
|
||||
<Border Grid.Row="1" Background="Black">
|
||||
<vlc:VideoView x:Name="Video" />
|
||||
</Border>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
|
||||
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
|
||||
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
108
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
Normal file
108
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using LibVLCSharp.Shared;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views.Blogs;
|
||||
|
||||
public partial class VideoPage : ContentPage
|
||||
{
|
||||
private LibVLC? _libVlc;
|
||||
private MediaPlayer? _player;
|
||||
private Media? _media;
|
||||
|
||||
public VideoPage() => InitializeComponent();
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
ReleasePlayer();
|
||||
if (DataContext is not VideoPageViewModel vm)
|
||||
return;
|
||||
try
|
||||
{
|
||||
Core.Initialize();
|
||||
_libVlc = new LibVLC();
|
||||
_player = new MediaPlayer(_libVlc);
|
||||
_player.EncounteredError += Player_EncounteredError;
|
||||
_media = new Media(_libVlc, vm.Source);
|
||||
Video.MediaPlayer = _player;
|
||||
PlayButton.IsEnabled = true;
|
||||
PlaybackStatus.Text = vm.MimeType;
|
||||
if (!_player.Play(_media))
|
||||
PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur.";
|
||||
}
|
||||
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
|
||||
or BadImageFormatException or PlatformNotSupportedException)
|
||||
{
|
||||
ReleasePlayer();
|
||||
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC.";
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
|
||||
{
|
||||
ReleasePlayer();
|
||||
base.OnNavigatedFrom(e);
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ReleasePlayer();
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
private void ReleasePlayer()
|
||||
{
|
||||
PlayButton.IsEnabled = false;
|
||||
Video.MediaPlayer = null;
|
||||
if (_player is not null)
|
||||
{
|
||||
_player.EncounteredError -= Player_EncounteredError;
|
||||
_player.Stop();
|
||||
_player.Dispose();
|
||||
_player = null;
|
||||
}
|
||||
_media?.Dispose();
|
||||
_media = null;
|
||||
_libVlc?.Dispose();
|
||||
_libVlc = null;
|
||||
}
|
||||
|
||||
private void Player_EncounteredError(object? sender, EventArgs e)
|
||||
=> Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ReferenceEquals(sender, _player))
|
||||
PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur.";
|
||||
});
|
||||
|
||||
private void PlayPause_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_player is null || _media is null)
|
||||
return;
|
||||
if (_player.IsPlaying)
|
||||
_player.Pause();
|
||||
else if (!_player.Play())
|
||||
PlaybackStatus.Text = "Impossible de reprendre la vidéo.";
|
||||
}
|
||||
|
||||
private async void OpenExternal_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not VideoPageViewModel vm)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this)
|
||||
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
|
||||
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
|
||||
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PlaybackStatus.Text = $"Impossible d'ouvrir le navigateur : {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue