feat: implement cross-device reading progress synchronization using SignalR and remove legacy quiz generation services.

This commit is contained in:
2026-05-02 19:55:07 +02:00
parent e5611758f1
commit 94ecc7a404
22 changed files with 332 additions and 69 deletions
@@ -10,6 +10,7 @@
@inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
@inject ISyncService SyncService
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@if (ViewModel == null)
@@ -77,6 +78,11 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await SyncService.InitializeAsync();
}
if (ViewModel != null && !_isJsInitialized)
{
_isJsInitialized = true;
@@ -109,6 +115,23 @@
public void HandleBlockReached(string blockId, string content)
{
Coordinator.OnBlockReached(blockId, content);
// Debounce sync update (simple version: every 5 seconds or on a timer)
_ = SyncService.UpdateProgressAsync(blockId);
}
private void HandleSyncProgressReceived(string blockId, DateTime timestamp)
{
// For now, let's just scroll to the node if it's in the current view,
// or just log it. Usually, we should prompt the user.
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
// Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
// but we can assume incoming syncs are from other active devices)
_ = InvokeAsync(async () => {
await ScrollToNodeAsync(blockId);
StateHasChanged();
});
}
[JSInvokable]
@@ -196,5 +219,6 @@
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
}
}
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,11 @@
using FluentResults;
namespace NexusReader.UI.Shared.Services;
public interface ISyncService
{
Task<Result> InitializeAsync();
Task<Result> UpdateProgressAsync(string pageId);
event Action<string, DateTime> OnProgressReceived;
Task DisposeAsync();
}
@@ -0,0 +1,112 @@
using FluentResults;
using Microsoft.AspNetCore.SignalR.Client;
using NexusReader.Application.Abstractions.Services;
using System.Net.Http;
namespace NexusReader.UI.Shared.Services;
public class SyncService : ISyncService, IAsyncDisposable
{
private readonly HttpClient _httpClient;
private readonly INativeStorageService _storageService;
private readonly IPlatformService _platformService;
private HubConnection? _hubConnection;
private bool _isInitialized;
private CancellationTokenSource? _debounceCts;
public event Action<string, DateTime>? OnProgressReceived;
public SyncService(
HttpClient httpClient,
INativeStorageService storageService,
IPlatformService platformService)
{
_httpClient = httpClient;
_storageService = storageService;
_platformService = platformService;
}
public async Task<Result> InitializeAsync()
{
if (_isInitialized) return Result.Ok();
var tokenResult = await _storageService.GetSecureString("nexus_auth_token");
if (tokenResult.IsFailed) return Result.Fail("Not authenticated");
var baseUrl = _httpClient.BaseAddress?.ToString() ?? "http://localhost:5000/";
var hubUrl = new Uri(new Uri(baseUrl), "synchub").ToString();
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(tokenResult.Value);
})
.WithAutomaticReconnect()
.Build();
_hubConnection.On<string, DateTime>("ProgressUpdated", (pageId, timestamp) =>
{
OnProgressReceived?.Invoke(pageId, timestamp);
});
try
{
await _hubConnection.StartAsync();
_isInitialized = true;
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
private string? _lastSentPageId;
public async Task<Result> UpdateProgressAsync(string pageId)
{
if (pageId == _lastSentPageId) return Result.Ok();
// Proper trailing-edge debounce
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var token = _debounceCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(2000, token);
if (!_isInitialized) await InitializeAsync();
if (_hubConnection?.State == HubConnectionState.Connected)
{
await _hubConnection.SendAsync("UpdateProgress", pageId, token);
_lastSentPageId = pageId;
}
}
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
catch (Exception ex)
{
Console.WriteLine($"[SyncService] Error sending progress: {ex.Message}");
}
}, token);
return Result.Ok();
}
public async Task DisposeAsync()
{
_debounceCts?.Cancel();
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
await DisposeAsync();
}
}