feat(PostIt): add dedicated blog reader and video playback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-16 13:31:57 +01:00
commit dade93bbfc
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
18 changed files with 827 additions and 20 deletions

View file

@ -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`)

View file

@ -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 `![texte](url)`
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

View file

@ -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" />

View 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);
}
}

View 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));
}
}

View file

@ -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

View file

@ -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;

View file

@ -44,6 +44,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<MainWindow>();
services.AddTransient<MainView>();
services.AddTransient<BlogsPage>();
services.AddTransient<VideoPage>();
// Models

View file

@ -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" />

View 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);
}
}

View 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) + "/");
}
}

View file

@ -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>(),

View file

@ -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

View 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 { } }
}

View file

@ -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>

View file

@ -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();
}
}

View 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>

View 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}";
}
}
}