diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1019d3e13..951ce2d05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## [1.0.8-rc27] - unstable
+
+### Added
+
+* [PostIt] Service `CameraStreamer` (préliminaire) : détection des
+ périphériques de capture vidéo (DirectShow sous Windows via le paquet
+ `DirectShowLib`, `/dev/video*` sous Linux) et création d'un média
+ LibVLC de capture pour le périphérique choisi.
+
+### Changed
+
+* [PostIt] La page des blogs passe en deux colonnes lorsque la largeur de
+ la fenêtre atteint sa hauteur : billet à gauche, recherche, commandes et
+ liste à droite, avec séparateur vertical redimensionnable. La disposition
+ verticale revient en portrait sans perdre le brouillon ; la barre de
+ navigation est plus compacte en mode large et conserve le bouton Retour.
+
+### Fixed
+
+* [PostIt] Le changement de disposition de la page des blogs est différé
+ sur le thread UI, évitant l'exception « Visual was invalidated during
+ the render pass » au redimensionnement.
+
## [1.0.8-rc26] - unstable
### Added
diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md
index 439bb8f70..476137a8b 100644
--- a/doc/architecture/postit.md
+++ b/doc/architecture/postit.md
@@ -178,6 +178,20 @@ et les commandes restent visibles, tandis que la liste des billets et
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
de modifier cette répartition. La liste et l'article défilent séparément.
+Lorsque la largeur de la fenêtre atteint ou dépasse sa hauteur, le lecteur
+ou l'éditeur passe à gauche (deux tiers de la largeur par défaut), et
+l'en-tête, la recherche, les commandes et la liste passent à droite.
+Le séparateur devient vertical. En portrait, la disposition verticale
+revient sans recréer les contrôles ni perdre le brouillon.
+Le seuil utilise les dimensions clientes de la fenêtre, indépendamment
+de la hauteur occupée par la navigation et la bannière de session.
+
+La page applique `NavigationPage.BarHeightOverride` à 36 en mode large ;
+le `NavRoot` de `MainView` conserve ainsi le bouton Retour, sans changer
+les autres pages. En portrait, la hauteur de navigation par défaut revient.
+Les commandes ont un contexte de données explicite pour conserver leurs
+liaisons, y compris lorsqu'elles passent dans le menu de débordement.
+
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props
index f0b523488..fbfaa1f38 100644
--- a/src/PostIt/Directory.Packages.props
+++ b/src/PostIt/Directory.Packages.props
@@ -16,6 +16,7 @@
+
@@ -37,4 +38,4 @@
-
+
\ No newline at end of file
diff --git a/src/PostIt/PostIt.Tests/BlogReaderTests.cs b/src/PostIt/PostIt.Tests/BlogReaderTests.cs
index b56e88ccf..9bf6d800e 100644
--- a/src/PostIt/PostIt.Tests/BlogReaderTests.cs
+++ b/src/PostIt/PostIt.Tests/BlogReaderTests.cs
@@ -2,7 +2,9 @@ using System.Net;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
+using Avalonia.Headless;
using Avalonia.Headless.XUnit;
+using Avalonia.Input;
using Avalonia.Logging;
using Avalonia.Threading;
using Avalonia.VisualTree;
@@ -95,6 +97,7 @@ public class BlogReaderTests
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Assert.True(page.FindControl("PostReader")!.IsVisible);
Assert.False(page.FindControl("PostEditor")!.IsVisible);
Assert.False(page.SaveButton.IsVisible);
@@ -164,6 +167,141 @@ public class BlogReaderTests
}
}
+ [AvaloniaTheory]
+ [InlineData(360, 800, false)]
+ [InlineData(1000, 420, true)]
+ [InlineData(1000, 280, true)]
+ [InlineData(600, 600, true)]
+ [InlineData(599, 600, false)]
+ [InlineData(601, 600, true)]
+ public async Task Blog_layout_uses_columns_from_the_square_window_threshold(int width, int height, bool wide)
+ {
+ var vm = CreateViewModel();
+ await vm.InitializeAsync();
+ for (var i = 0; i < 40; i++)
+ vm.FilteredPosts.Add(Post($"Billet {i}"));
+ vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
+ var page = new BlogsPage { DataContext = vm };
+ var main = new MainView();
+ var nav = main.FindControl("NavRoot")!;
+ await nav.PushAsync(new ContentPage());
+ await nav.PushAsync(page);
+ var window = new Window { Width = width, Height = height, Content = main };
+ try
+ {
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ window.UpdateLayout();
+
+ var reader = page.FindControl("PostReader")!;
+ var list = page.FindControl("PostsListBorder")!;
+ var splitter = page.FindControl("PostsSplitter")!;
+ Assert.Equal(wide ? GridResizeDirection.Columns : GridResizeDirection.Rows, splitter.ResizeDirection);
+ Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page));
+ Assert.True(nav.IsBackButtonEffectivelyVisible);
+ Assert.True(reader.Bounds.Width > 0 && reader.Bounds.Height > 0);
+ Assert.True(list.Bounds.Width > 0 && list.Bounds.Height > 0);
+ if (wide)
+ {
+ Assert.True(reader.Bounds.Right <= list.Bounds.Left);
+ Assert.Equal(2, Grid.GetColumn(page.BlogsHeaderPanel));
+ Assert.Equal(2, Grid.GetColumn(page.BlogsCommandBar));
+ Assert.Equal(reader.Margin.Top, reader.Bounds.Top);
+ Assert.Equal(page.StatusBar.Bounds.Top - reader.Margin.Bottom, reader.Bounds.Bottom);
+ Assert.Equal(36, page.TranslatePoint(new Point(), nav)!.Value.Y);
+ }
+ else
+ {
+ Assert.True(list.Bounds.Bottom <= reader.Bounds.Top);
+ Assert.Equal(0, Grid.GetColumn(page.BlogsHeaderPanel));
+ Assert.Equal(0, Grid.GetColumn(page.BlogsCommandBar));
+ }
+
+ var articleScroll = page.FindControl("ArticleReader")!
+ .GetVisualDescendants().OfType().Single();
+ Assert.InRange(articleScroll.Viewport.Height, 1, window.ClientSize.Height);
+ Assert.True(articleScroll.Extent.Height > articleScroll.Viewport.Height);
+ var listScroll = page.PostsListBox.GetVisualDescendants().OfType().Single();
+ Assert.InRange(listScroll.Viewport.Height, 1, window.ClientSize.Height);
+ Assert.True(listScroll.Extent.Height > listScroll.Viewport.Height);
+
+ var originalReaderSize = reader.Bounds.Size;
+ splitter.Focus();
+ splitter.RaiseEvent(new KeyEventArgs
+ {
+ RoutedEvent = InputElement.KeyDownEvent,
+ Key = wide ? Key.Right : Key.Down
+ });
+ window.UpdateLayout();
+ if (wide)
+ Assert.True(reader.Bounds.Width > originalReaderSize.Width);
+ else
+ Assert.True(reader.Bounds.Height < originalReaderSize.Height);
+
+ page.BlogsCommandBar.IsOpen = true;
+ Dispatcher.UIThread.RunJobs();
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ Assert.Same(vm, page.SaveButton.DataContext);
+ Assert.False(page.SaveButton.IsVisible);
+ Assert.True(page.EditPostButton.IsVisible);
+ vm.EditPostCommand.Execute(null);
+ Dispatcher.UIThread.RunJobs();
+ Assert.True(page.SaveButton.IsVisible);
+ Assert.Same(vm.SaveCommand, page.SaveButton.Command);
+ Assert.False(page.EditPostButton.IsVisible);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public async Task Resizing_between_portrait_and_landscape_preserves_the_editor_and_draft()
+ {
+ var vm = CreateViewModel();
+ await vm.InitializeAsync();
+ vm.SelectedPost = Post();
+ vm.EditPostCommand.Execute(null);
+ vm.DraftArticle = "Brouillon conservé pendant le redimensionnement.";
+ var document = vm.DraftArticleDocument;
+ var page = new BlogsPage { DataContext = vm };
+ var editor = page.FindControl("PostEditor")!;
+ var nav = new NavigationPage();
+ await nav.PushAsync(page);
+ var window = new Window { Width = 360, Height = 800, Content = nav };
+ try
+ {
+ window.Show();
+ foreach (var size in new[] { new Size(360, 800), new Size(1000, 420), new Size(600, 600), new Size(599, 600), new Size(360, 800) })
+ {
+ window.Width = size.Width;
+ window.Height = size.Height;
+ Dispatcher.UIThread.RunJobs();
+ window.UpdateLayout();
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+
+ var wide = size.Width >= size.Height;
+ Assert.Equal(wide ? 0 : 4, Grid.GetRow(editor));
+ Assert.Equal(wide ? 3 : 1, Grid.GetRowSpan(editor));
+ Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page));
+ Assert.Same(editor, page.FindControl("PostEditor"));
+ Assert.True(editor.IsVisible);
+ Assert.False(page.FindControl("PostReader")!.IsVisible);
+ Assert.True(vm.IsEditing);
+ Assert.True(vm.IsDraftModified);
+ Assert.Equal("Brouillon conservé pendant le redimensionnement.", vm.DraftArticle);
+ Assert.Same(document, vm.DraftArticleDocument);
+ Assert.Equal(42, vm.SelectedPost!.Id);
+ Assert.True(editor.Bounds.Width > 0 && editor.Bounds.Height > 0);
+ }
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
[AvaloniaFact]
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
{
diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj
index 31c5afbda..27bdf298c 100644
--- a/src/PostIt/PostIt/PostIt.csproj
+++ b/src/PostIt/PostIt/PostIt.csproj
@@ -32,6 +32,7 @@
None
All
+
diff --git a/src/PostIt/PostIt/Services/CameraStreamer.cs b/src/PostIt/PostIt/Services/CameraStreamer.cs
new file mode 100644
index 000000000..10607f204
--- /dev/null
+++ b/src/PostIt/PostIt/Services/CameraStreamer.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using LibVLCSharp.Shared;
+
+// Note : Si vous compilez sous Linux, DirectShowLib n'est pas disponible.
+// Pour un projet multiplateforme propre, vous pouvez utiliser des directives de précompilation (#if WINDOWS)
+// ou charger la détection DirectShow dans une assembly séparée.
+using DirectShowLib;
+
+public class CameraStreamer
+{
+ private readonly LibVLC _libVLC;
+
+ public CameraStreamer(LibVLC libVLC)
+ {
+ _libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC));
+ }
+
+ ///
+ /// Récupère la liste des périphériques de capture disponibles sur la machine.
+ /// Retourne un dictionnaire : Clé = Nom lisible, Valeur = Identifiant/Chemin système
+ ///
+ public Dictionary GetCaptureDevices()
+ {
+ var devices = new Dictionary();
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // Détection Windows via DirectShow
+ DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice);
+ foreach (var device in capDevices)
+ {
+ // Pour Windows, VLC utilise le nom de l'appareil
+ devices.Add(device.Name, device.Name);
+ }
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ // Détection Linux via V4L2 (Video4Linux2)
+ if (Directory.Exists("/dev"))
+ {
+ var videoFiles = Directory.GetFiles("/dev", "video*");
+ foreach (var path in videoFiles)
+ {
+ // Exemple : Clé = "Caméra (/dev/video0)", Valeur = "/dev/video0"
+ devices.Add($"Caméra ({path})", path);
+ }
+ }
+ }
+ else
+ {
+ throw new PlatformNotSupportedException("OS non supporté pour la capture automatique dans cet exemple.");
+ }
+
+ return devices;
+ }
+
+ ///
+ /// Crée un objet Media configuré pour le périphérique sélectionné.
+ ///
+ /// Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux)
+ public Media CreateCaptureMedia(string deviceValue)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // Configuration pour DirectShow (Windows)
+ var media = new Media(_libVLC, "dshow://", FromType.FromLocation);
+ media.AddOption($":dshow-vdev={deviceValue}");
+
+ // Options optionnelles (Exemple : Forcer la résolution ou le framerate)
+ // media.AddOption(":dshow-size=1280x720");
+ // media.AddOption(":dshow-fps=30");
+
+ return media;
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ // Configuration pour V4L2 (Linux)
+ var media = new Media(_libVLC, "v4l2://", FromType.FromLocation);
+ media.AddOption($":v4l2-vdev={deviceValue}");
+
+ // Options optionnelles pour Linux
+ // media.AddOption(":v4l2-width=1280");
+ // media.AddOption(":v4l2-height=720");
+
+ return media;
+ }
+
+ throw new PlatformNotSupportedException();
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
index d29d1f020..1d387ad3a 100644
--- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
@@ -1,5 +1,6 @@
-
@@ -45,18 +46,19 @@
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
-
+
-
-
+
-
-
+
@@ -100,6 +105,7 @@
-
+
-
-
-
-
-
-
-
@@ -178,14 +180,16 @@
-
@@ -237,7 +241,7 @@
-
@@ -263,7 +267,6 @@
Text="Choisissez un billet dans la liste pour le lire."
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
-
-
+
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
index fec5da5bf..0583ad563 100644
--- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
@@ -1,6 +1,8 @@
using System.IO;
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Layout;
using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
@@ -13,14 +15,57 @@ public partial class BlogsPage : ContentPage
{
private static readonly RelayCommand DisabledLinkCommand =
new(static _ => { }, static _ => false);
+ private bool? _isWideLayout;
public BlogsPage()
{
InitializeComponent();
+ SizeChanged += (_, _) =>
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ UpdateResponsiveLayout();
+ });
DataContextChanged += (_, _) => UpdateHyperlinkCommand();
UpdateHyperlinkCommand();
}
+ private void UpdateResponsiveLayout()
+ {
+ var size = TopLevel.GetTopLevel(this)?.ClientSize ?? Bounds.Size;
+ if (size.Width <= 0 || size.Height <= 0)
+ return;
+
+ // Use the window size so compacting the navigation bar cannot change the orientation.
+ var isWide = size.Width >= size.Height;
+ if (_isWideLayout == isWide)
+ return;
+ _isWideLayout = isWide;
+
+ MainGrid.RowDefinitions = new RowDefinitions(isWide ? "Auto,Auto,*,Auto" : "Auto,Auto,*,4,2*,Auto");
+ MainGrid.ColumnDefinitions = new ColumnDefinitions(isWide ? "2*,4,*" : "*");
+ var sidebarColumn = isWide ? 2 : 0;
+ SetArea(BlogsHeaderPanel, 0, sidebarColumn);
+ SetArea(BlogsCommandBar, 1, sidebarColumn);
+ SetArea(PostsListBorder, 2, sidebarColumn);
+ SetArea(PostsSplitter, isWide ? 0 : 3, isWide ? 1 : 0, isWide ? 3 : 1);
+ SetArea(PostReader, isWide ? 0 : 4, 0, isWide ? 3 : 1);
+ SetArea(PostEditor, isWide ? 0 : 4, 0, isWide ? 3 : 1);
+ SetArea(StatusBar, isWide ? 3 : 5, 0, columnSpan: isWide ? 3 : 1);
+ PostsSplitter.ResizeDirection = isWide ? GridResizeDirection.Columns : GridResizeDirection.Rows;
+ PostsSplitter.HorizontalAlignment = isWide ? HorizontalAlignment.Center : HorizontalAlignment.Stretch;
+ PostsSplitter.VerticalAlignment = isWide ? VerticalAlignment.Stretch : VerticalAlignment.Center;
+ PostsSplitter.Margin = isWide ? new Thickness(0, 12) : new Thickness(12, 0);
+ NavigationPage.SetBarHeightOverride(this, isWide ? 36 : null);
+ }
+
+ private static void SetArea(Control control, int row, int column, int rowSpan = 1, int columnSpan = 1)
+ {
+ Grid.SetRow(control, row);
+ Grid.SetColumn(control, column);
+ Grid.SetRowSpan(control, rowSpan);
+ Grid.SetColumnSpan(control, columnSpan);
+ }
+
private void UpdateHyperlinkCommand()
{
if (ArticleReader.Engine is IMarkdownEngine2 engine)