feat: implement cross-device reading progress synchronization using SignalR and remove legacy quiz generation services.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user