From fd8caa3b7ceb0627c42f90f1218a936334588641 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Sat, 10 Oct 2026 03:00:38 +0330 Subject: [PATCH] Fix player dock UX: full-bleed LTR controls and delayed resume save. Persist listening progress only after ~2.5s of real playback so opening an ayah no longer overwrites resume. Co-authored-by: Cursor --- .../Components/AudioPlayer.razor | 40 ++++++++++---- .../Components/PlayerDock.razor | 3 + src/TafsirQuran.UI/Pages/TafsirPage.razor | 1 + .../Services/PlayerSessionService.cs | 55 ++++++++++++++++++- src/TafsirQuran.UI/wwwroot/css/app.css | 46 ++++++++++------ 5 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/TafsirQuran.UI/Components/AudioPlayer.razor b/src/TafsirQuran.UI/Components/AudioPlayer.razor index 84fb15c..bf1b7da 100644 --- a/src/TafsirQuran.UI/Components/AudioPlayer.razor +++ b/src/TafsirQuran.UI/Components/AudioPlayer.razor @@ -2,8 +2,8 @@ @inject ApiOptions ApiOptions @implements IAsyncDisposable -
-
+
+
@if (ShowFavorite) @@ -22,18 +22,18 @@ @if (!string.IsNullOrWhiteSpace(AudioUrl)) { -
-
@Format(current) / @Format(duration)
- +
+ +
@Format(current) / @Format(duration)
} else { -

صوت این آیه در دسترس نیست

+

صوت این آیه در دسترس نیست

}
@@ -50,6 +50,7 @@ [Parameter] public EventCallback OnBookmarkToggle { get; set; } [Parameter] public EventCallback OnProgress { get; set; } [Parameter] public EventCallback OnTitleClick { get; set; } + [Parameter] public EventCallback OnPlayingChanged { get; set; } private ElementReference audioEl; private ElementReference root; @@ -59,6 +60,7 @@ private double duration; private string boundKey = ""; private bool bound; + private double lastReported = -1; private string AudioSrc => ResolveAudioUrl(AudioUrl ?? ""); @@ -79,6 +81,7 @@ boundKey = key; playing = false; current = InitialPosition; + lastReported = -1; await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition); } } @@ -94,6 +97,7 @@ private async Task TogglePlay() { playing = await Js.InvokeAsync("tqAudio.toggle", audioEl); + await OnPlayingChanged.InvokeAsync(playing); } private async Task OnSeek(ChangeEventArgs e) @@ -111,17 +115,33 @@ { current = time; duration = dur > 0 ? dur : duration; - playing = isPlaying; + + if (isPlaying != playing) + { + playing = isPlaying; + await OnPlayingChanged.InvokeAsync(isPlaying); + } + await InvokeAsync(StateHasChanged); - if (isPlaying && Math.Floor(time) % 5 == 0) + + // Throttle UI→session position reports (~1s) + if (isPlaying && Math.Abs(time - lastReported) >= 0.9) + { + lastReported = time; await OnProgress.InvokeAsync(time); + } } [JSInvokable] public async Task OnPaused(double time) { - playing = false; + if (playing) + { + playing = false; + await OnPlayingChanged.InvokeAsync(false); + } current = time; + lastReported = time; await OnProgress.InvokeAsync(time); await InvokeAsync(StateHasChanged); } diff --git a/src/TafsirQuran.UI/Components/PlayerDock.razor b/src/TafsirQuran.UI/Components/PlayerDock.razor index e9dd401..3c6503c 100644 --- a/src/TafsirQuran.UI/Components/PlayerDock.razor +++ b/src/TafsirQuran.UI/Components/PlayerDock.razor @@ -15,6 +15,7 @@ OnFavoriteToggle="Session.ToggleFavoriteAsync" OnBookmarkToggle="Session.ToggleBookmarkAsync" OnProgress="OnProgress" + OnPlayingChanged="OnPlayingChanged" OnTitleClick="GoToTrack" />
} @@ -28,6 +29,8 @@ await Session.EnsureLoadedAsync(); } + private void OnPlayingChanged(bool isPlaying) => Session.MarkPlaying(isPlaying); + private async Task OnProgress(double position) => await Session.ReportPositionAsync(position); private void GoToTrack() diff --git a/src/TafsirQuran.UI/Pages/TafsirPage.razor b/src/TafsirQuran.UI/Pages/TafsirPage.razor index 244157d..47f8a86 100644 --- a/src/TafsirQuran.UI/Pages/TafsirPage.razor +++ b/src/TafsirQuran.UI/Pages/TafsirPage.razor @@ -95,6 +95,7 @@ } else { +

سوره @detail.SurahNameFa — آیه @detail.AyahNumber

@detail.TextAr

@if (!string.IsNullOrWhiteSpace(detail.TextFa)) diff --git a/src/TafsirQuran.UI/Services/PlayerSessionService.cs b/src/TafsirQuran.UI/Services/PlayerSessionService.cs index a0190b7..b68aae5 100644 --- a/src/TafsirQuran.UI/Services/PlayerSessionService.cs +++ b/src/TafsirQuran.UI/Services/PlayerSessionService.cs @@ -5,10 +5,16 @@ namespace TafsirQuran.UI.Services; public sealed class PlayerSessionService { + private const double PersistAfterPlayedSeconds = 2.5; + private readonly TafsirApiClient _api; private readonly IDeviceIdStore _devices; private bool _loaded; private string _deviceId = ""; + private DateTimeOffset? _playSegmentStarted; + private double _playedAccumulated; + private bool _persistedForTrack; + private DateTimeOffset _lastPersistAt = DateTimeOffset.MinValue; public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices) { @@ -52,6 +58,7 @@ public sealed class PlayerSessionService AyahNumber = progress.AyahNumber; SurahNameFa = progress.SurahNameFa ?? ""; PositionSeconds = progress.PositionSeconds; + _persistedForTrack = true; var tafsirs = await _api.GetTafsirsAsync(); var tafsir = tafsirs.FirstOrDefault(t => t.Id == progress.TafsirId); @@ -78,6 +85,10 @@ public sealed class PlayerSessionService public async Task SetTrackAsync(AyahDetailDto detail, double position = 0) { await EnsureDeviceAsync(); + var same = TafsirId == detail.TafsirId + && SurahNumber == detail.SurahNumber + && AyahNumber == detail.AyahNumber; + TafsirId = detail.TafsirId; TafsirSlug = detail.TafsirSlug; SurahNumber = detail.SurahNumber; @@ -85,15 +96,57 @@ public sealed class PlayerSessionService SurahNameFa = detail.SurahNameFa; AudioUrl = detail.AudioUrl; PositionSeconds = position; + + if (!same) + { + _playSegmentStarted = null; + _playedAccumulated = 0; + _persistedForTrack = false; + } + await SyncFlagsAsync(); Notify(); - await PersistAsync(position); + // Do not persist yet — wait until user actually plays ~2.5s + } + + public void MarkPlaying(bool isPlaying) + { + if (isPlaying) + { + _playSegmentStarted ??= DateTimeOffset.UtcNow; + } + else if (_playSegmentStarted is DateTimeOffset start) + { + _playedAccumulated += (DateTimeOffset.UtcNow - start).TotalSeconds; + _playSegmentStarted = null; + } } public async Task ReportPositionAsync(double position) { if (!HasTrack) return; PositionSeconds = position; + + var played = _playedAccumulated; + if (_playSegmentStarted is DateTimeOffset start) + played += (DateTimeOffset.UtcNow - start).TotalSeconds; + + // Only save resume after ~2.5s of real playback on this track + if (played < PersistAfterPlayedSeconds) + { + Notify(); + return; + } + + var now = DateTimeOffset.UtcNow; + if (_persistedForTrack && (now - _lastPersistAt).TotalSeconds < 4) + { + Notify(); + return; + } + + _persistedForTrack = true; + _lastPersistAt = now; await PersistAsync(position); Notify(); } diff --git a/src/TafsirQuran.UI/wwwroot/css/app.css b/src/TafsirQuran.UI/wwwroot/css/app.css index 60dd22f..87d928a 100644 --- a/src/TafsirQuran.UI/wwwroot/css/app.css +++ b/src/TafsirQuran.UI/wwwroot/css/app.css @@ -291,9 +291,13 @@ button, input { font: inherit; } } .title-author { font-weight: 500; color: var(--muted); font-size: 0.92em; } .back-btn { - flex-shrink: 0; width: 42px; height: 42px; border-radius: 12px; + flex-shrink: 0; width: 52px; height: 52px; border-radius: 14px; border: 1px solid var(--line); background: #fff; cursor: pointer; - font-size: 1.25rem; line-height: 1; color: var(--brand-deep); + font-size: 1.6rem; line-height: 1; color: var(--brand-deep); + display: grid; place-items: center; +} +.ayah-page-title { + margin: 0 0 0.85rem; font-size: 1.1rem; font-weight: 700; color: var(--brand-deep); } .skeleton-stack { display: grid; gap: 0.65rem; margin: 0.5rem 0 1rem; } @@ -313,39 +317,45 @@ button, input { font: inherit; } .player-dock { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; - padding: 0.65rem 0.85rem calc(0.65rem + env(safe-area-inset-bottom)); - background: rgba(243, 239, 230, 0.94); - backdrop-filter: blur(14px); + padding: 0; + padding-bottom: env(safe-area-inset-bottom); + background: #fff; border-top: 1px solid var(--line); box-shadow: 0 -10px 28px rgba(28, 43, 36, 0.08); } .dock-player { - max-width: 980px; margin: 0 auto; - border: 1px solid var(--line); + max-width: none; margin: 0; width: 100%; + border: 0; border-radius: 0; background: #fff; - border-radius: 18px; - padding: 0.75rem 0.9rem 0.85rem; + padding: 0.85rem 1rem 0.95rem; direction: rtl; text-align: right; + box-sizing: border-box; } .dock-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; - margin-bottom: 0.55rem; + margin-bottom: 0.65rem; } .dock-title { border: 0; background: transparent; padding: 0; cursor: pointer; - font-weight: 700; font-size: 0.98rem; color: var(--ink); text-align: right; + font-weight: 700; font-size: 1.05rem; color: var(--ink); text-align: right; } -.dock-actions { display: flex; gap: 0.35rem; flex-shrink: 0; } +.dock-actions { display: flex; gap: 0.45rem; flex-shrink: 0; } .icon-chip { - width: 36px; height: 36px; border-radius: 10px; - border: 1px solid var(--line); background: #fff; cursor: pointer; - color: var(--muted); font-size: 1rem; + width: 44px; height: 44px; border-radius: 12px; + border: 1px solid rgba(28, 43, 36, 0.22); background: #f3f6f4; cursor: pointer; + color: #1c2b24; font-size: 1.35rem; + display: grid; place-items: center; } .icon-chip.on { background: var(--brand); color: #fff; border-color: var(--brand); } -.rtl-controls { - display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center; - direction: rtl; +.ltr-controls { + display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center; + direction: ltr; +} +.ltr-controls .seek { direction: ltr; } +.ltr-controls .time { + font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); white-space: nowrap; + min-width: 5.5rem; text-align: left; } .dock-empty { margin: 0; font-size: 0.9rem; }