Tap the dock to expand; open فهرست for the current surah list with ±15s skips and remaining time, without interrupting the single audio session. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,10 +2,14 @@
|
|||||||
@inject ApiOptions ApiOptions
|
@inject ApiOptions ApiOptions
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<div class="audio-player dock-player" @ref="root">
|
<div class="@(Layout == PlayerLayout.Expanded ? "audio-player player-sheet" : "audio-player dock-player")"
|
||||||
|
@ref="root"
|
||||||
|
@onclick="OnRootClick">
|
||||||
|
@if (Layout == PlayerLayout.Dock)
|
||||||
|
{
|
||||||
<div class="dock-top" dir="rtl">
|
<div class="dock-top" dir="rtl">
|
||||||
<button type="button" class="dock-title" @onclick="OnTitleClick">@Title</button>
|
<button type="button" class="dock-title" @onclick="OnExpandClick" @onclick:stopPropagation="true">@Title</button>
|
||||||
<div class="dock-actions">
|
<div class="dock-actions" @onclick:stopPropagation="true">
|
||||||
@if (ShowFavorite)
|
@if (ShowFavorite)
|
||||||
{
|
{
|
||||||
<button type="button" class="icon-chip @(IsFavorite ? "on" : null)" title="علاقه"
|
<button type="button" class="icon-chip @(IsFavorite ? "on" : null)" title="علاقه"
|
||||||
@@ -19,14 +23,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(AudioUrl))
|
@if (HasAudio)
|
||||||
{
|
{
|
||||||
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
<div class="player-controls ltr-controls" @onclick:stopPropagation="true">
|
||||||
<div class="player-controls ltr-controls">
|
|
||||||
<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"
|
<input class="seek" type="range" min="0" max="@SeekMax" step="0.1"
|
||||||
value="@current.ToString("0.###")" @oninput="OnSeek" dir="ltr" />
|
value="@current.ToString("0.###")" @oninput="OnSeek" dir="ltr" />
|
||||||
<div class="time">@Format(current) / @Format(duration)</div>
|
<div class="time">@Format(current) / @Format(duration)</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,10 +38,92 @@
|
|||||||
{
|
{
|
||||||
<p class="muted dock-empty" dir="rtl">صوت این آیه در دسترس نیست</p>
|
<p class="muted dock-empty" dir="rtl">صوت این آیه در دسترس نیست</p>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="sheet-header" dir="rtl" @onclick:stopPropagation="true">
|
||||||
|
<button type="button" class="sheet-icon-btn" @onclick="OnCollapseClick" aria-label="بستن" title="بستن">∨</button>
|
||||||
|
<div class="sheet-header-title">@HeaderTitle</div>
|
||||||
|
<button type="button" class="sheet-icon-btn" @onclick="OnOpenTextClick" aria-label="متن آیه" title="متن آیه">☰</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-hero" aria-hidden="true">
|
||||||
|
<div class="player-hero-inner">
|
||||||
|
<span class="player-hero-kicker">@HeaderTitle</span>
|
||||||
|
<strong>@SurahLabel</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-track-meta" dir="rtl">
|
||||||
|
<div class="sheet-track-name">@SurahLabel — آیه @AyahNumber</div>
|
||||||
|
@if (SurahAyahCount > 0)
|
||||||
|
{
|
||||||
|
<div class="sheet-track-index">@AyahNumber / @SurahAyahCount</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-actions" dir="rtl" @onclick:stopPropagation="true">
|
||||||
|
<button type="button" class="sheet-action" @onclick="OnOpenTocClick">
|
||||||
|
<span class="sheet-action-icon">≡</span>
|
||||||
|
<span>فهرست</span>
|
||||||
|
</button>
|
||||||
|
@if (ShowFavorite)
|
||||||
|
{
|
||||||
|
<button type="button" class="sheet-action @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">
|
||||||
|
<span class="sheet-action-icon">♥</span>
|
||||||
|
<span>علاقه</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (ShowBookmark)
|
||||||
|
{
|
||||||
|
<button type="button" class="sheet-action @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">
|
||||||
|
<span class="sheet-action-icon">⚑</span>
|
||||||
|
<span>نشان</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (HasAudio)
|
||||||
|
{
|
||||||
|
<div class="sheet-seek-block" dir="ltr" @onclick:stopPropagation="true">
|
||||||
|
<input class="seek sheet-seek" type="range" min="0" max="@SeekMax" step="0.1"
|
||||||
|
value="@current.ToString("0.###")" @oninput="OnSeek" dir="ltr" />
|
||||||
|
<div class="sheet-times">
|
||||||
|
<span>@Format(current)</span>
|
||||||
|
<span class="sheet-remain">@Format(Remaining) باقی مانده</span>
|
||||||
|
<span>@Format(duration)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-main-controls" dir="ltr" @onclick:stopPropagation="true">
|
||||||
|
<button type="button" class="skip-btn" @onclick="() => SeekBy(-15)" aria-label="۱۵ ثانیه عقب">−۱۵</button>
|
||||||
|
<button type="button" class="play-btn play-btn-lg" @onclick="TogglePlay" aria-label="پخش">
|
||||||
|
@(playing ? "❚❚" : "▶")
|
||||||
|
</button>
|
||||||
|
<button type="button" class="skip-btn" @onclick="() => SeekBy(15)" aria-label="۱۵ ثانیه جلو">+۱۵</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted dock-empty" dir="rtl">صوت این آیه در دسترس نیست</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (HasAudio)
|
||||||
|
{
|
||||||
|
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
public enum PlayerLayout { Dock, Expanded }
|
||||||
|
|
||||||
[Parameter, EditorRequired] public string Title { get; set; } = "";
|
[Parameter, EditorRequired] public string Title { get; set; } = "";
|
||||||
|
[Parameter] public string HeaderTitle { get; set; } = "";
|
||||||
|
[Parameter] public string SurahLabel { get; set; } = "";
|
||||||
|
[Parameter] public int AyahNumber { get; set; }
|
||||||
|
[Parameter] public int SurahAyahCount { get; set; }
|
||||||
|
[Parameter] public PlayerLayout Layout { get; set; } = PlayerLayout.Dock;
|
||||||
[Parameter] public string? AudioUrl { get; set; }
|
[Parameter] public string? AudioUrl { get; set; }
|
||||||
[Parameter] public string AudioKey { get; set; } = "";
|
[Parameter] public string AudioKey { get; set; } = "";
|
||||||
[Parameter] public double InitialPosition { get; set; }
|
[Parameter] public double InitialPosition { get; set; }
|
||||||
@@ -49,7 +134,10 @@
|
|||||||
[Parameter] public EventCallback OnFavoriteToggle { get; set; }
|
[Parameter] public EventCallback OnFavoriteToggle { get; set; }
|
||||||
[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 OnExpand { get; set; }
|
||||||
|
[Parameter] public EventCallback OnCollapse { get; set; }
|
||||||
|
[Parameter] public EventCallback OnOpenToc { get; set; }
|
||||||
|
[Parameter] public EventCallback OnOpenText { get; set; }
|
||||||
[Parameter] public EventCallback<bool> OnPlayingChanged { get; set; }
|
[Parameter] public EventCallback<bool> OnPlayingChanged { get; set; }
|
||||||
|
|
||||||
private ElementReference audioEl;
|
private ElementReference audioEl;
|
||||||
@@ -62,11 +150,14 @@
|
|||||||
private bool bound;
|
private bool bound;
|
||||||
private double lastReported = -1;
|
private double lastReported = -1;
|
||||||
|
|
||||||
|
private bool HasAudio => !string.IsNullOrWhiteSpace(AudioUrl);
|
||||||
private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
|
private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
|
||||||
|
private string SeekMax => Math.Max(duration, 0.1).ToString("0.###");
|
||||||
|
private double Remaining => Math.Max(0, duration - current);
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(AudioUrl)) return;
|
if (!HasAudio) return;
|
||||||
|
|
||||||
if (!bound)
|
if (!bound)
|
||||||
{
|
{
|
||||||
@@ -94,12 +185,26 @@
|
|||||||
return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString();
|
return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task OnRootClick() =>
|
||||||
|
Layout == PlayerLayout.Dock ? OnExpand.InvokeAsync() : Task.CompletedTask;
|
||||||
|
|
||||||
|
private Task OnExpandClick() => OnExpand.InvokeAsync();
|
||||||
|
private Task OnCollapseClick() => OnCollapse.InvokeAsync();
|
||||||
|
private Task OnOpenTocClick() => OnOpenToc.InvokeAsync();
|
||||||
|
private Task OnOpenTextClick() => OnOpenText.InvokeAsync();
|
||||||
|
|
||||||
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);
|
await OnPlayingChanged.InvokeAsync(playing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SeekBy(double delta)
|
||||||
|
{
|
||||||
|
current = await Js.InvokeAsync<double>("tqAudio.seekBy", audioEl, delta);
|
||||||
|
await OnProgress.InvokeAsync(current);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnSeek(ChangeEventArgs e)
|
private async Task OnSeek(ChangeEventArgs e)
|
||||||
{
|
{
|
||||||
if (double.TryParse(e.Value?.ToString(), out var v))
|
if (double.TryParse(e.Value?.ToString(), out var v))
|
||||||
@@ -124,7 +229,6 @@
|
|||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
// Throttle UI→session position reports (~1s)
|
|
||||||
if (isPlaying && Math.Abs(time - lastReported) >= 0.9)
|
if (isPlaying && Math.Abs(time - lastReported) >= 0.9)
|
||||||
{
|
{
|
||||||
lastReported = time;
|
lastReported = time;
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
|
|
||||||
@if (Session.HasTrack)
|
@if (Session.HasTrack)
|
||||||
{
|
{
|
||||||
<div class="player-dock">
|
<div class="@(Session.IsExpanded ? "player-expanded-host" : "player-dock")">
|
||||||
<AudioPlayer @key="@($"{Session.SurahNumber}:{Session.AyahNumber}:{Session.AudioUrl}")"
|
<AudioPlayer @key="@($"{Session.TafsirSlug}:{Session.SurahNumber}:{Session.AyahNumber}")"
|
||||||
|
Layout="@(Session.IsExpanded ? AudioPlayer.PlayerLayout.Expanded : AudioPlayer.PlayerLayout.Dock)"
|
||||||
Title="@Session.DisplayTitle"
|
Title="@Session.DisplayTitle"
|
||||||
|
HeaderTitle="@Session.TafsirTitle"
|
||||||
|
SurahLabel="@($"سوره {Session.SurahNameFa}")"
|
||||||
|
AyahNumber="Session.AyahNumber"
|
||||||
|
SurahAyahCount="Session.SurahAyahCount"
|
||||||
AudioUrl="@Session.AudioUrl"
|
AudioUrl="@Session.AudioUrl"
|
||||||
AudioKey="@($"{Session.SurahNumber}:{Session.AyahNumber}")"
|
AudioKey="@($"{Session.SurahNumber}:{Session.AyahNumber}")"
|
||||||
InitialPosition="Session.PositionSeconds"
|
InitialPosition="Session.PositionSeconds"
|
||||||
@@ -16,8 +21,13 @@
|
|||||||
OnBookmarkToggle="Session.ToggleBookmarkAsync"
|
OnBookmarkToggle="Session.ToggleBookmarkAsync"
|
||||||
OnProgress="OnProgress"
|
OnProgress="OnProgress"
|
||||||
OnPlayingChanged="OnPlayingChanged"
|
OnPlayingChanged="OnPlayingChanged"
|
||||||
OnTitleClick="GoToTrack" />
|
OnExpand="Session.Expand"
|
||||||
|
OnCollapse="Session.Collapse"
|
||||||
|
OnOpenToc="Session.OpenToc"
|
||||||
|
OnOpenText="GoToTrack" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PlayerToc />
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -35,7 +45,8 @@
|
|||||||
|
|
||||||
private void GoToTrack()
|
private void GoToTrack()
|
||||||
{
|
{
|
||||||
if (Session.HasTrack)
|
if (!Session.HasTrack) return;
|
||||||
|
Session.Collapse();
|
||||||
Nav.NavigateTo(Session.ResumeHref);
|
Nav.NavigateTo(Session.ResumeHref);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
@inject PlayerSessionService Session
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@if (Session.IsTocOpen && Session.HasTrack)
|
||||||
|
{
|
||||||
|
<div class="toc-sheet" dir="rtl">
|
||||||
|
<div class="toc-header">
|
||||||
|
<button type="button" class="sheet-icon-btn" @onclick="Session.CloseToc" aria-label="بازگشت">›</button>
|
||||||
|
<div class="toc-header-text">
|
||||||
|
<strong>سوره @Session.SurahNameFa</strong>
|
||||||
|
<span class="muted">@Session.TafsirTitle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading)
|
||||||
|
{
|
||||||
|
<div class="skeleton-stack toc-skeleton">
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (ayahs.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted" style="padding:1rem;">آیهای یافت نشد</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="toc-list">
|
||||||
|
@foreach (var a in ayahs)
|
||||||
|
{
|
||||||
|
var active = a.AyahNumber == Session.AyahNumber;
|
||||||
|
<li class="toc-row @(active ? "active" : null)">
|
||||||
|
<button type="button" class="toc-row-btn" @onclick="() => Select(a.AyahNumber)">
|
||||||
|
@if (active)
|
||||||
|
{
|
||||||
|
<span class="toc-play-dot" aria-hidden="true">▶</span>
|
||||||
|
}
|
||||||
|
<span class="toc-row-main">
|
||||||
|
<span class="toc-row-title">آیه @a.AyahNumber</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(a.TextFaPreview))
|
||||||
|
{
|
||||||
|
<span class="toc-row-preview">@a.TextFaPreview</span>
|
||||||
|
}
|
||||||
|
<span class="toc-row-flags">
|
||||||
|
@if (a.IsFavorite) { <span title="علاقه">♥</span> }
|
||||||
|
@if (a.IsBookmarked) { <span title="نشان">⚑</span> }
|
||||||
|
@if (!a.HasAudio) { <span class="muted">بدون صوت</span> }
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="toc-row-num">@a.AyahNumber</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<AyahListItemDto> ayahs = new();
|
||||||
|
private bool loading;
|
||||||
|
private int loadedForSurah = -1;
|
||||||
|
private string loadedSlug = "";
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Session.Changed += OnChanged;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
await EnsureLoadedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (Session.IsTocOpen)
|
||||||
|
await EnsureLoadedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureLoadedAsync()
|
||||||
|
{
|
||||||
|
if (!Session.IsTocOpen || !Session.HasTrack) return;
|
||||||
|
if (loadedForSurah == Session.SurahNumber && loadedSlug == Session.TafsirSlug && ayahs.Count > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ayahs = (await Session.GetCurrentSurahAyahsAsync()).ToList();
|
||||||
|
loadedForSurah = Session.SurahNumber;
|
||||||
|
loadedSlug = Session.TafsirSlug;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ayahs = new();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Select(int ayahNumber) => await Session.PlayAyahAsync(ayahNumber);
|
||||||
|
|
||||||
|
private async void OnChanged()
|
||||||
|
{
|
||||||
|
if (Session.IsTocOpen &&
|
||||||
|
(loadedForSurah != Session.SurahNumber || loadedSlug != Session.TafsirSlug))
|
||||||
|
{
|
||||||
|
loadedForSurah = -1;
|
||||||
|
await EnsureLoadedAsync();
|
||||||
|
}
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Session.Changed -= OnChanged;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public sealed class PlayerSessionService
|
|||||||
private double _playedAccumulated;
|
private double _playedAccumulated;
|
||||||
private bool _persistedForTrack;
|
private bool _persistedForTrack;
|
||||||
private DateTimeOffset _lastPersistAt = DateTimeOffset.MinValue;
|
private DateTimeOffset _lastPersistAt = DateTimeOffset.MinValue;
|
||||||
|
private int _ayahCountCachedForSurah = -1;
|
||||||
|
|
||||||
public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices)
|
public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices)
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,7 @@ public sealed class PlayerSessionService
|
|||||||
|
|
||||||
public Guid TafsirId { get; private set; }
|
public Guid TafsirId { get; private set; }
|
||||||
public string TafsirSlug { get; private set; } = "ghatreh-chekani";
|
public string TafsirSlug { get; private set; } = "ghatreh-chekani";
|
||||||
|
public string TafsirTitle { get; private set; } = "تفسیر قطرهچکانی";
|
||||||
public int SurahNumber { get; private set; }
|
public int SurahNumber { get; private set; }
|
||||||
public int AyahNumber { get; private set; }
|
public int AyahNumber { get; private set; }
|
||||||
public string SurahNameFa { get; private set; } = "";
|
public string SurahNameFa { get; private set; } = "";
|
||||||
@@ -35,6 +37,9 @@ public sealed class PlayerSessionService
|
|||||||
public double PositionSeconds { get; private set; }
|
public double PositionSeconds { get; private set; }
|
||||||
public bool IsFavorite { get; private set; }
|
public bool IsFavorite { get; private set; }
|
||||||
public bool IsBookmarked { get; private set; }
|
public bool IsBookmarked { get; private set; }
|
||||||
|
public int SurahAyahCount { get; private set; }
|
||||||
|
public bool IsExpanded { get; private set; }
|
||||||
|
public bool IsTocOpen { get; private set; }
|
||||||
|
|
||||||
public string DisplayTitle =>
|
public string DisplayTitle =>
|
||||||
string.IsNullOrWhiteSpace(SurahNameFa)
|
string.IsNullOrWhiteSpace(SurahNameFa)
|
||||||
@@ -63,6 +68,7 @@ public sealed class PlayerSessionService
|
|||||||
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);
|
||||||
TafsirSlug = tafsir?.Slug ?? "ghatreh-chekani";
|
TafsirSlug = tafsir?.Slug ?? "ghatreh-chekani";
|
||||||
|
TafsirTitle = tafsir?.Title ?? TafsirTitle;
|
||||||
|
|
||||||
var detail = await _api.GetAyahAsync(TafsirSlug, SurahNumber, AyahNumber);
|
var detail = await _api.GetAyahAsync(TafsirSlug, SurahNumber, AyahNumber);
|
||||||
if (detail is not null)
|
if (detail is not null)
|
||||||
@@ -70,6 +76,7 @@ public sealed class PlayerSessionService
|
|||||||
AudioUrl = detail.AudioUrl;
|
AudioUrl = detail.AudioUrl;
|
||||||
SurahNameFa = detail.SurahNameFa;
|
SurahNameFa = detail.SurahNameFa;
|
||||||
await SyncFlagsAsync();
|
await SyncFlagsAsync();
|
||||||
|
await RefreshSurahAyahCountAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +95,7 @@ public sealed class PlayerSessionService
|
|||||||
var same = TafsirId == detail.TafsirId
|
var same = TafsirId == detail.TafsirId
|
||||||
&& SurahNumber == detail.SurahNumber
|
&& SurahNumber == detail.SurahNumber
|
||||||
&& AyahNumber == detail.AyahNumber;
|
&& AyahNumber == detail.AyahNumber;
|
||||||
|
var sameSurah = TafsirId == detail.TafsirId && SurahNumber == detail.SurahNumber;
|
||||||
|
|
||||||
TafsirId = detail.TafsirId;
|
TafsirId = detail.TafsirId;
|
||||||
TafsirSlug = detail.TafsirSlug;
|
TafsirSlug = detail.TafsirSlug;
|
||||||
@@ -104,9 +112,71 @@ public sealed class PlayerSessionService
|
|||||||
_persistedForTrack = false;
|
_persistedForTrack = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sameSurah)
|
||||||
|
_ayahCountCachedForSurah = -1;
|
||||||
|
|
||||||
|
await EnsureTafsirTitleAsync();
|
||||||
await SyncFlagsAsync();
|
await SyncFlagsAsync();
|
||||||
|
if (_ayahCountCachedForSurah != SurahNumber)
|
||||||
|
await RefreshSurahAyahCountAsync();
|
||||||
Notify();
|
Notify();
|
||||||
// Do not persist yet — wait until user actually plays ~2.5s
|
}
|
||||||
|
|
||||||
|
public void Expand()
|
||||||
|
{
|
||||||
|
if (!HasTrack) return;
|
||||||
|
IsExpanded = true;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Collapse()
|
||||||
|
{
|
||||||
|
IsExpanded = false;
|
||||||
|
IsTocOpen = false;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenToc()
|
||||||
|
{
|
||||||
|
if (!HasTrack) return;
|
||||||
|
IsExpanded = true;
|
||||||
|
IsTocOpen = true;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CloseToc()
|
||||||
|
{
|
||||||
|
IsTocOpen = false;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayAyahAsync(int ayahNumber)
|
||||||
|
{
|
||||||
|
if (!HasTrack || ayahNumber <= 0) return;
|
||||||
|
await EnsureDeviceAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var detail = await _api.GetAyahAsync(TafsirSlug, SurahNumber, ayahNumber);
|
||||||
|
if (detail is null) return;
|
||||||
|
await SetTrackAsync(detail, 0);
|
||||||
|
IsTocOpen = false;
|
||||||
|
IsExpanded = true;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AyahListItemDto>> GetCurrentSurahAyahsAsync()
|
||||||
|
{
|
||||||
|
await EnsureDeviceAsync();
|
||||||
|
if (!HasTrack) return Array.Empty<AyahListItemDto>();
|
||||||
|
var list = await _api.GetAyahsAsync(TafsirSlug, SurahNumber, _deviceId);
|
||||||
|
SurahAyahCount = list.Count;
|
||||||
|
_ayahCountCachedForSurah = SurahNumber;
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkPlaying(bool isPlaying)
|
public void MarkPlaying(bool isPlaying)
|
||||||
@@ -131,7 +201,6 @@ public sealed class PlayerSessionService
|
|||||||
if (_playSegmentStarted is DateTimeOffset start)
|
if (_playSegmentStarted is DateTimeOffset start)
|
||||||
played += (DateTimeOffset.UtcNow - start).TotalSeconds;
|
played += (DateTimeOffset.UtcNow - start).TotalSeconds;
|
||||||
|
|
||||||
// Only save resume after ~2.5s of real playback on this track
|
|
||||||
if (played < PersistAfterPlayedSeconds)
|
if (played < PersistAfterPlayedSeconds)
|
||||||
{
|
{
|
||||||
Notify();
|
Notify();
|
||||||
@@ -175,6 +244,36 @@ public sealed class PlayerSessionService
|
|||||||
Notify();
|
Notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RefreshSurahAyahCountAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureDeviceAsync();
|
||||||
|
var list = await _api.GetAyahsAsync(TafsirSlug, SurahNumber, _deviceId);
|
||||||
|
SurahAyahCount = list.Count;
|
||||||
|
_ayahCountCachedForSurah = SurahNumber;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureTafsirTitleAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tafsirs = await _api.GetTafsirsAsync();
|
||||||
|
var tafsir = tafsirs.FirstOrDefault(t => t.Id == TafsirId || t.Slug == TafsirSlug);
|
||||||
|
if (tafsir is not null)
|
||||||
|
TafsirTitle = tafsir.Title;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SyncFlagsAsync()
|
private async Task SyncFlagsAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -368,3 +368,178 @@ button, input { font: inherit; }
|
|||||||
}
|
}
|
||||||
.resume-label { font-weight: 700; color: var(--brand-deep); }
|
.resume-label { font-weight: 700; color: var(--brand-deep); }
|
||||||
.resume-meta { color: var(--muted); font-size: 0.9rem; }
|
.resume-meta { color: var(--muted); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* Expanded player sheet */
|
||||||
|
.player-expanded-host {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
background: #f7f5f0;
|
||||||
|
}
|
||||||
|
.player-sheet {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f7f5f0;
|
||||||
|
padding: calc(0.65rem + env(safe-area-inset-top)) 1.15rem calc(1.1rem + env(safe-area-inset-bottom));
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.sheet-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 1fr 44px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.sheet-header-title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.sheet-icon-btn {
|
||||||
|
width: 44px; height: 44px; border-radius: 12px;
|
||||||
|
border: 1px solid var(--line); background: #fff;
|
||||||
|
cursor: pointer; font-size: 1.35rem; color: var(--brand-deep);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.player-hero {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin: 0.25rem auto 1rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(19, 70, 50, 0.92), rgba(31, 107, 74, 0.78)),
|
||||||
|
url('/background.png') center/cover;
|
||||||
|
box-shadow: 0 18px 40px rgba(28, 43, 36, 0.18);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.player-hero-inner {
|
||||||
|
display: grid; gap: 0.45rem; text-align: center; padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.player-hero-kicker {
|
||||||
|
font-size: 0.9rem; opacity: 0.85;
|
||||||
|
}
|
||||||
|
.player-hero-inner strong {
|
||||||
|
font-size: 1.45rem; font-weight: 800; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sheet-track-meta {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.sheet-track-name {
|
||||||
|
font-size: 1.15rem; font-weight: 700; color: var(--ink);
|
||||||
|
}
|
||||||
|
.sheet-track-index {
|
||||||
|
margin-top: 0.25rem; color: var(--muted); font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.sheet-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.sheet-action {
|
||||||
|
border: 0; background: transparent; cursor: pointer;
|
||||||
|
display: grid; gap: 0.3rem; justify-items: center;
|
||||||
|
color: var(--ink); font-size: 0.85rem; padding: 0.35rem;
|
||||||
|
}
|
||||||
|
.sheet-action.on { color: var(--brand); font-weight: 700; }
|
||||||
|
.sheet-action-icon {
|
||||||
|
width: 44px; height: 44px; border-radius: 12px;
|
||||||
|
border: 1px solid rgba(28, 43, 36, 0.18); background: #fff;
|
||||||
|
display: grid; place-items: center; font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.sheet-action.on .sheet-action-icon {
|
||||||
|
background: var(--brand); color: #fff; border-color: var(--brand);
|
||||||
|
}
|
||||||
|
.sheet-seek-block { margin-top: auto; padding-top: 0.5rem; }
|
||||||
|
.sheet-seek { width: 100%; accent-color: var(--brand); }
|
||||||
|
.sheet-times {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.sheet-times > span:first-child { text-align: left; }
|
||||||
|
.sheet-times > span:last-child { text-align: right; }
|
||||||
|
.sheet-remain { text-align: center; color: var(--brand-deep); }
|
||||||
|
.player-main-controls {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 1.4rem; margin: 1.35rem 0 0.4rem;
|
||||||
|
}
|
||||||
|
.play-btn-lg {
|
||||||
|
width: 72px; height: 72px; font-size: 1.35rem;
|
||||||
|
background: #1c2b24;
|
||||||
|
}
|
||||||
|
.skip-btn {
|
||||||
|
width: 52px; height: 52px; border-radius: 50%;
|
||||||
|
border: 1px solid rgba(28, 43, 36, 0.22); background: #fff;
|
||||||
|
cursor: pointer; font-weight: 700; color: var(--brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOC sheet */
|
||||||
|
.toc-sheet {
|
||||||
|
position: fixed; inset: 0; z-index: 70;
|
||||||
|
background: #fff;
|
||||||
|
padding: calc(0.65rem + env(safe-area-inset-top)) 0 calc(0.5rem + env(safe-area-inset-bottom));
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.toc-header {
|
||||||
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
|
padding: 0 1rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.toc-header-text {
|
||||||
|
display: grid; gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.toc-header-text strong { font-size: 1.15rem; color: var(--ink); }
|
||||||
|
.toc-skeleton { padding: 1rem; }
|
||||||
|
.toc-list {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
overflow: auto; flex: 1;
|
||||||
|
}
|
||||||
|
.toc-row {
|
||||||
|
border-bottom: 1px solid rgba(28, 43, 36, 0.08);
|
||||||
|
}
|
||||||
|
.toc-row.active { background: rgba(28, 43, 36, 0.06); }
|
||||||
|
.toc-row-btn {
|
||||||
|
width: 100%; border: 0; background: transparent; cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
text-align: right;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.toc-play-dot {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--brand); color: #fff;
|
||||||
|
display: grid; place-items: center; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.toc-row:not(.active) .toc-row-btn { grid-template-columns: 1fr auto; }
|
||||||
|
.toc-row-main { display: grid; gap: 0.2rem; min-width: 0; }
|
||||||
|
.toc-row-title { font-weight: 700; color: var(--ink); }
|
||||||
|
.toc-row-preview {
|
||||||
|
color: var(--muted); font-size: 0.88rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toc-row-flags {
|
||||||
|
display: flex; gap: 0.4rem; font-size: 0.85rem; color: var(--brand-deep);
|
||||||
|
}
|
||||||
|
.toc-row-num {
|
||||||
|
font-weight: 700; color: var(--muted); min-width: 1.5rem; text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,5 +59,24 @@ window.tqAudio = {
|
|||||||
seek: (audio, value) => {
|
seek: (audio, value) => {
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
audio.currentTime = Number(value) || 0;
|
audio.currentTime = Number(value) || 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
seekBy: (audio, deltaSeconds) => {
|
||||||
|
if (!audio) return 0;
|
||||||
|
const dur = Number.isFinite(audio.duration) ? audio.duration : 0;
|
||||||
|
let next = (audio.currentTime || 0) + (Number(deltaSeconds) || 0);
|
||||||
|
if (next < 0) next = 0;
|
||||||
|
if (dur > 0 && next > dur) next = dur;
|
||||||
|
audio.currentTime = next;
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
|
||||||
|
getState: (audio) => {
|
||||||
|
if (!audio) return { time: 0, duration: 0, playing: false };
|
||||||
|
return {
|
||||||
|
time: audio.currentTime || 0,
|
||||||
|
duration: audio.duration || 0,
|
||||||
|
playing: !audio.paused
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user