Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Asks the user for a folder. Returns <c>null</c> when the dialog is dismissed.</summary>
|
||||
public interface IFolderPicker
|
||||
{
|
||||
Task<string?> PickFolderAsync(string title, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the parts of <see cref="Application.Library.LibraryOptions"/> the user can change
|
||||
/// at runtime. Writes land in a JSON file that is also a configuration source, so
|
||||
/// <c>IOptionsMonitor</c> picks the change up without a restart.
|
||||
/// </summary>
|
||||
public interface ILibrarySettingsStore
|
||||
{
|
||||
Task SaveFoldersAsync(IReadOnlyList<string> folders, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="ILibrarySettingsStore"/>
|
||||
public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
||||
|
||||
public async Task SaveFoldersAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var root = await ReadRootAsync(cancellationToken);
|
||||
|
||||
if (root[LibraryOptions.SectionName] is not JsonObject section)
|
||||
{
|
||||
section = [];
|
||||
root[LibraryOptions.SectionName] = section;
|
||||
}
|
||||
|
||||
section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]);
|
||||
|
||||
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||
var staging = SettingsFile + ".tmp";
|
||||
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
||||
File.Move(staging, SettingsFile, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(SettingsFile))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(SettingsFile, cancellationToken);
|
||||
return JsonNode.Parse(json) as JsonObject ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A hand-edited, broken settings file should not stop the app from saving.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="IFolderPicker"/>
|
||||
public sealed class StorageProviderFolderPicker : IFolderPicker
|
||||
{
|
||||
public async Task<string?> PickFolderAsync(string title, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{
|
||||
MainWindow.StorageProvider: { } storageProvider,
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = title,
|
||||
AllowMultiple = false,
|
||||
});
|
||||
|
||||
return folders.Count > 0 ? folders[0].TryGetLocalPath() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Hands a file over to whatever the operating system uses to open or show it.</summary>
|
||||
public interface ISystemShell
|
||||
{
|
||||
void OpenFile(string path);
|
||||
|
||||
void RevealInFileManager(string path);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISystemShell"/>
|
||||
public sealed class SystemShell(ILogger<SystemShell> logger) : ISystemShell
|
||||
{
|
||||
public void OpenFile(string path) => Start(new ProcessStartInfo(path) { UseShellExecute = true }, path);
|
||||
|
||||
public void RevealInFileManager(string path)
|
||||
{
|
||||
ProcessStartInfo startInfo;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
startInfo = new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"");
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
startInfo = new ProcessStartInfo("open", ["-R", path]);
|
||||
}
|
||||
else
|
||||
{
|
||||
var folder = Path.GetDirectoryName(path);
|
||||
|
||||
if (folder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startInfo = new ProcessStartInfo("xdg-open", [folder]);
|
||||
}
|
||||
|
||||
Start(startInfo, path);
|
||||
}
|
||||
|
||||
private void Start(ProcessStartInfo startInfo, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Nothing actionable for the user here; a missing handler is not a crash.
|
||||
logger.LogWarning(ex, "Could not hand {Path} to the shell", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Switches the application between the light and dark variants.</summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
void Toggle();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IThemeService"/>
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
public void Toggle()
|
||||
{
|
||||
if (Avalonia.Application.Current is not { } application)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ActualThemeVariant resolves "follow the system" to whatever is on screen right now,
|
||||
// which is what the user is actually toggling away from.
|
||||
application.RequestedThemeVariant = application.ActualThemeVariant == ThemeVariant.Dark
|
||||
? ThemeVariant.Light
|
||||
: ThemeVariant.Dark;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user