using FluentResults; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; using NexusReader.Application.Abstractions.Services; namespace NexusReader.UI.Shared.Services; public class SyncService : ISyncService, IAsyncDisposable { private readonly HttpClient _httpClient; private readonly INativeStorageService _storageService; private readonly IPlatformService _platformService; private readonly ILogger _logger; private HubConnection? _hubConnection; private bool _isInitialized; private CancellationTokenSource? _debounceCts; public event Func? OnProgressReceived; public event Func? OnIngestionProgressReceived; public SyncService( HttpClient httpClient, INativeStorageService storageService, IPlatformService platformService, ILogger logger) { _httpClient = httpClient; _storageService = storageService; _platformService = platformService; _logger = logger; } public async Task 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(tokenResult.Value); }) .WithAutomaticReconnect() .Build(); _hubConnection.On("ProgressUpdated", async (pageId, timestamp) => { // Note: In the future we might want to receive ebookId and progress here too if (pageId == _lastSentPageId) { _logger.LogDebug("[Sync] Ignoring self progress update for page {PageId}.", pageId); return; } _lastSentPageId = pageId; // Prevent echoing back duplicate progress updates if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp); }); _hubConnection.On("IngestionProgress", async (message, progress) => { if (OnIngestionProgressReceived != null) await OnIngestionProgressReceived(message, progress); }); try { await _hubConnection.StartAsync(); _isInitialized = true; return Result.Ok(); } catch (Exception ex) { return Result.Fail(ex.Message); } } private string? _lastSentPageId; public async Task UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle, int chapterIndex) { if (pageId == _lastSentPageId) return Result.Ok(); _lastSentPageId = pageId; // 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, ebookId, progress, chapterTitle, chapterIndex); } } catch (TaskCanceledException) { /* Ignored, user kept scrolling */ } catch (Exception ex) { _logger.LogError(ex, "[SyncService] Error sending reading progress for page {PageId}.", pageId); } }, token); return Result.Ok(); } public async Task DisposeAsync() { _debounceCts?.Cancel(); if (_hubConnection != null) { await _hubConnection.DisposeAsync(); } } async ValueTask IAsyncDisposable.DisposeAsync() { await DisposeAsync(); } }