Fix player dock UX: full-bleed LTR controls and delayed resume save.
build-and-deploy / deploy (push) Successful in 4m43s
build-and-deploy / deploy (push) Successful in 4m43s
Persist listening progress only after ~2.5s of real playback so opening an ayah no longer overwrites resume. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
@inject ApiOptions ApiOptions
|
@inject ApiOptions ApiOptions
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<div class="audio-player dock-player" @ref="root" dir="rtl">
|
<div class="audio-player dock-player" @ref="root">
|
||||||
<div class="dock-top">
|
<div class="dock-top" dir="rtl">
|
||||||
<button type="button" class="dock-title" @onclick="OnTitleClick">@Title</button>
|
<button type="button" class="dock-title" @onclick="OnTitleClick">@Title</button>
|
||||||
<div class="dock-actions">
|
<div class="dock-actions">
|
||||||
@if (ShowFavorite)
|
@if (ShowFavorite)
|
||||||
@@ -22,18 +22,18 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(AudioUrl))
|
@if (!string.IsNullOrWhiteSpace(AudioUrl))
|
||||||
{
|
{
|
||||||
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
||||||
<div class="player-controls rtl-controls">
|
<div class="player-controls ltr-controls">
|
||||||
<div class="time">@Format(current) / @Format(duration)</div>
|
|
||||||
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
|
|
||||||
value="@current.ToString("0.###")" @oninput="OnSeek" />
|
|
||||||
<button type="button" class="play-btn" @onclick="TogglePlay" aria-label="پخش">
|
<button type="button" class="play-btn" @onclick="TogglePlay" aria-label="پخش">
|
||||||
@(playing ? "❚❚" : "▶")
|
@(playing ? "❚❚" : "▶")
|
||||||
</button>
|
</button>
|
||||||
|
<input class="seek" type="range" min="0" max="@Math.Max(duration, 0.1).ToString("0.###")" step="0.1"
|
||||||
|
value="@current.ToString("0.###")" @oninput="OnSeek" dir="ltr" />
|
||||||
|
<div class="time">@Format(current) / @Format(duration)</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p class="muted dock-empty">صوت این آیه در دسترس نیست</p>
|
<p class="muted dock-empty" dir="rtl">صوت این آیه در دسترس نیست</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
[Parameter] public EventCallback OnBookmarkToggle { get; set; }
|
[Parameter] public EventCallback OnBookmarkToggle { get; set; }
|
||||||
[Parameter] public EventCallback<double> OnProgress { get; set; }
|
[Parameter] public EventCallback<double> OnProgress { get; set; }
|
||||||
[Parameter] public EventCallback OnTitleClick { get; set; }
|
[Parameter] public EventCallback OnTitleClick { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> OnPlayingChanged { get; set; }
|
||||||
|
|
||||||
private ElementReference audioEl;
|
private ElementReference audioEl;
|
||||||
private ElementReference root;
|
private ElementReference root;
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
private double duration;
|
private double duration;
|
||||||
private string boundKey = "";
|
private string boundKey = "";
|
||||||
private bool bound;
|
private bool bound;
|
||||||
|
private double lastReported = -1;
|
||||||
|
|
||||||
private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
|
private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@
|
|||||||
boundKey = key;
|
boundKey = key;
|
||||||
playing = false;
|
playing = false;
|
||||||
current = InitialPosition;
|
current = InitialPosition;
|
||||||
|
lastReported = -1;
|
||||||
await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition);
|
await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,7 @@
|
|||||||
private async Task TogglePlay()
|
private async Task TogglePlay()
|
||||||
{
|
{
|
||||||
playing = await Js.InvokeAsync<bool>("tqAudio.toggle", audioEl);
|
playing = await Js.InvokeAsync<bool>("tqAudio.toggle", audioEl);
|
||||||
|
await OnPlayingChanged.InvokeAsync(playing);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnSeek(ChangeEventArgs e)
|
private async Task OnSeek(ChangeEventArgs e)
|
||||||
@@ -111,17 +115,33 @@
|
|||||||
{
|
{
|
||||||
current = time;
|
current = time;
|
||||||
duration = dur > 0 ? dur : duration;
|
duration = dur > 0 ? dur : duration;
|
||||||
|
|
||||||
|
if (isPlaying != playing)
|
||||||
|
{
|
||||||
playing = isPlaying;
|
playing = isPlaying;
|
||||||
|
await OnPlayingChanged.InvokeAsync(isPlaying);
|
||||||
|
}
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
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);
|
await OnProgress.InvokeAsync(time);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public async Task OnPaused(double time)
|
public async Task OnPaused(double time)
|
||||||
|
{
|
||||||
|
if (playing)
|
||||||
{
|
{
|
||||||
playing = false;
|
playing = false;
|
||||||
|
await OnPlayingChanged.InvokeAsync(false);
|
||||||
|
}
|
||||||
current = time;
|
current = time;
|
||||||
|
lastReported = time;
|
||||||
await OnProgress.InvokeAsync(time);
|
await OnProgress.InvokeAsync(time);
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
OnFavoriteToggle="Session.ToggleFavoriteAsync"
|
OnFavoriteToggle="Session.ToggleFavoriteAsync"
|
||||||
OnBookmarkToggle="Session.ToggleBookmarkAsync"
|
OnBookmarkToggle="Session.ToggleBookmarkAsync"
|
||||||
OnProgress="OnProgress"
|
OnProgress="OnProgress"
|
||||||
|
OnPlayingChanged="OnPlayingChanged"
|
||||||
OnTitleClick="GoToTrack" />
|
OnTitleClick="GoToTrack" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,8 @@
|
|||||||
await Session.EnsureLoadedAsync();
|
await Session.EnsureLoadedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnPlayingChanged(bool isPlaying) => Session.MarkPlaying(isPlaying);
|
||||||
|
|
||||||
private async Task OnProgress(double position) => await Session.ReportPositionAsync(position);
|
private async Task OnProgress(double position) => await Session.ReportPositionAsync(position);
|
||||||
|
|
||||||
private void GoToTrack()
|
private void GoToTrack()
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<h2 class="ayah-page-title">سوره @detail.SurahNameFa — آیه @detail.AyahNumber</h2>
|
||||||
<div class="ayah-block">
|
<div class="ayah-block">
|
||||||
<p class="ayah-ar">@detail.TextAr</p>
|
<p class="ayah-ar">@detail.TextAr</p>
|
||||||
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
|
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ namespace TafsirQuran.UI.Services;
|
|||||||
|
|
||||||
public sealed class PlayerSessionService
|
public sealed class PlayerSessionService
|
||||||
{
|
{
|
||||||
|
private const double PersistAfterPlayedSeconds = 2.5;
|
||||||
|
|
||||||
private readonly TafsirApiClient _api;
|
private readonly TafsirApiClient _api;
|
||||||
private readonly IDeviceIdStore _devices;
|
private readonly IDeviceIdStore _devices;
|
||||||
private bool _loaded;
|
private bool _loaded;
|
||||||
private string _deviceId = "";
|
private string _deviceId = "";
|
||||||
|
private DateTimeOffset? _playSegmentStarted;
|
||||||
|
private double _playedAccumulated;
|
||||||
|
private bool _persistedForTrack;
|
||||||
|
private DateTimeOffset _lastPersistAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices)
|
public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices)
|
||||||
{
|
{
|
||||||
@@ -52,6 +58,7 @@ public sealed class PlayerSessionService
|
|||||||
AyahNumber = progress.AyahNumber;
|
AyahNumber = progress.AyahNumber;
|
||||||
SurahNameFa = progress.SurahNameFa ?? "";
|
SurahNameFa = progress.SurahNameFa ?? "";
|
||||||
PositionSeconds = progress.PositionSeconds;
|
PositionSeconds = progress.PositionSeconds;
|
||||||
|
_persistedForTrack = true;
|
||||||
|
|
||||||
var tafsirs = await _api.GetTafsirsAsync();
|
var tafsirs = await _api.GetTafsirsAsync();
|
||||||
var tafsir = tafsirs.FirstOrDefault(t => t.Id == progress.TafsirId);
|
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)
|
public async Task SetTrackAsync(AyahDetailDto detail, double position = 0)
|
||||||
{
|
{
|
||||||
await EnsureDeviceAsync();
|
await EnsureDeviceAsync();
|
||||||
|
var same = TafsirId == detail.TafsirId
|
||||||
|
&& SurahNumber == detail.SurahNumber
|
||||||
|
&& AyahNumber == detail.AyahNumber;
|
||||||
|
|
||||||
TafsirId = detail.TafsirId;
|
TafsirId = detail.TafsirId;
|
||||||
TafsirSlug = detail.TafsirSlug;
|
TafsirSlug = detail.TafsirSlug;
|
||||||
SurahNumber = detail.SurahNumber;
|
SurahNumber = detail.SurahNumber;
|
||||||
@@ -85,15 +96,57 @@ public sealed class PlayerSessionService
|
|||||||
SurahNameFa = detail.SurahNameFa;
|
SurahNameFa = detail.SurahNameFa;
|
||||||
AudioUrl = detail.AudioUrl;
|
AudioUrl = detail.AudioUrl;
|
||||||
PositionSeconds = position;
|
PositionSeconds = position;
|
||||||
|
|
||||||
|
if (!same)
|
||||||
|
{
|
||||||
|
_playSegmentStarted = null;
|
||||||
|
_playedAccumulated = 0;
|
||||||
|
_persistedForTrack = false;
|
||||||
|
}
|
||||||
|
|
||||||
await SyncFlagsAsync();
|
await SyncFlagsAsync();
|
||||||
Notify();
|
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)
|
public async Task ReportPositionAsync(double position)
|
||||||
{
|
{
|
||||||
if (!HasTrack) return;
|
if (!HasTrack) return;
|
||||||
PositionSeconds = position;
|
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);
|
await PersistAsync(position);
|
||||||
Notify();
|
Notify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,9 +291,13 @@ button, input { font: inherit; }
|
|||||||
}
|
}
|
||||||
.title-author { font-weight: 500; color: var(--muted); font-size: 0.92em; }
|
.title-author { font-weight: 500; color: var(--muted); font-size: 0.92em; }
|
||||||
.back-btn {
|
.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;
|
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; }
|
.skeleton-stack { display: grid; gap: 0.65rem; margin: 0.5rem 0 1rem; }
|
||||||
@@ -313,39 +317,45 @@ button, input { font: inherit; }
|
|||||||
|
|
||||||
.player-dock {
|
.player-dock {
|
||||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||||
padding: 0.65rem 0.85rem calc(0.65rem + env(safe-area-inset-bottom));
|
padding: 0;
|
||||||
background: rgba(243, 239, 230, 0.94);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
backdrop-filter: blur(14px);
|
background: #fff;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
box-shadow: 0 -10px 28px rgba(28, 43, 36, 0.08);
|
box-shadow: 0 -10px 28px rgba(28, 43, 36, 0.08);
|
||||||
}
|
}
|
||||||
.dock-player {
|
.dock-player {
|
||||||
max-width: 980px; margin: 0 auto;
|
max-width: none; margin: 0; width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 0; border-radius: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 18px;
|
padding: 0.85rem 1rem 0.95rem;
|
||||||
padding: 0.75rem 0.9rem 0.85rem;
|
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.dock-top {
|
.dock-top {
|
||||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem;
|
display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem;
|
||||||
margin-bottom: 0.55rem;
|
margin-bottom: 0.65rem;
|
||||||
}
|
}
|
||||||
.dock-title {
|
.dock-title {
|
||||||
border: 0; background: transparent; padding: 0; cursor: pointer;
|
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 {
|
.icon-chip {
|
||||||
width: 36px; height: 36px; border-radius: 10px;
|
width: 44px; height: 44px; border-radius: 12px;
|
||||||
border: 1px solid var(--line); background: #fff; cursor: pointer;
|
border: 1px solid rgba(28, 43, 36, 0.22); background: #f3f6f4; cursor: pointer;
|
||||||
color: var(--muted); font-size: 1rem;
|
color: #1c2b24; font-size: 1.35rem;
|
||||||
|
display: grid; place-items: center;
|
||||||
}
|
}
|
||||||
.icon-chip.on { background: var(--brand); color: #fff; border-color: var(--brand); }
|
.icon-chip.on { background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||||
.rtl-controls {
|
.ltr-controls {
|
||||||
display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center;
|
display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center;
|
||||||
direction: rtl;
|
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; }
|
.dock-empty { margin: 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user