Polish ayah UX with fixed player dock and progressive skeletons.
build-and-deploy / deploy (push) Successful in 4m45s

Compact header, sidebar surah list, closed notes, resume CTA, and larger ayah typography.
This commit is contained in:
2026-10-10 02:15:35 +03:30
parent f95c118efb
commit 3ca743ca12
11 changed files with 567 additions and 178 deletions
+20 -13
View File
@@ -2,35 +2,38 @@
@inject ApiOptions ApiOptions @inject ApiOptions ApiOptions
@implements IAsyncDisposable @implements IAsyncDisposable
<div class="audio-player" @ref="root"> <div class="audio-player dock-player" @ref="root" dir="rtl">
<div class="player-title">@Title</div> <div class="dock-top">
<div class="player-actions"> <button type="button" class="dock-title" @onclick="OnTitleClick">@Title</button>
<div class="dock-actions">
@if (ShowFavorite) @if (ShowFavorite)
{ {
<button type="button" class="chip @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">علاقه</button> <button type="button" class="icon-chip @(IsFavorite ? "on" : null)" title="علاقه"
@onclick="OnFavoriteToggle" aria-label="علاقه">♥</button>
} }
@if (ShowBookmark) @if (ShowBookmark)
{ {
<button type="button" class="chip @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">نشان</button> <button type="button" class="icon-chip @(IsBookmarked ? "on" : null)" title="نشان"
@onclick="OnBookmarkToggle" aria-label="نشان">⚑</button>
} }
</div> </div>
</div>
@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"> <div class="time">@Format(current) / @Format(duration)</div>
<button type="button" class="play-btn" @onclick="TogglePlay">
@(playing ? "❚❚" : "▶")
</button>
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1" <input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
value="@current.ToString("0.###")" @oninput="OnSeek" /> value="@current.ToString("0.###")" @oninput="OnSeek" />
<div class="time">@Format(current) / @Format(duration)</div> <button type="button" class="play-btn" @onclick="TogglePlay" aria-label="پخش">
@(playing ? "❚❚" : "▶")
</button>
</div> </div>
} }
else else
{ {
<p class="muted">صوت این آیه در دسترس نیست</p> <p class="muted dock-empty">صوت این آیه در دسترس نیست</p>
} }
</div> </div>
@@ -46,6 +49,7 @@
[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; }
private ElementReference audioEl; private ElementReference audioEl;
private ElementReference root; private ElementReference root;
@@ -54,6 +58,7 @@
private double current; private double current;
private double duration; private double duration;
private string boundKey = ""; private string boundKey = "";
private bool bound;
private string AudioSrc => ResolveAudioUrl(AudioUrl ?? ""); private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
@@ -61,10 +66,11 @@
{ {
if (string.IsNullOrWhiteSpace(AudioUrl)) return; if (string.IsNullOrWhiteSpace(AudioUrl)) return;
if (firstRender) if (!bound)
{ {
self = DotNetObjectReference.Create(this); self = DotNetObjectReference.Create(this);
await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self); await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self);
bound = true;
} }
var key = string.IsNullOrWhiteSpace(AudioKey) ? AudioSrc : AudioKey; var key = string.IsNullOrWhiteSpace(AudioKey) ? AudioSrc : AudioKey;
@@ -131,6 +137,7 @@
{ {
try try
{ {
if (bound)
await Js.InvokeVoidAsync("tqAudio.unbind", audioEl); await Js.InvokeVoidAsync("tqAudio.unbind", audioEl);
} }
catch { /* ignore */ } catch { /* ignore */ }
@@ -0,0 +1,42 @@
@inject PlayerSessionService Session
@inject NavigationManager Nav
@implements IDisposable
@if (Session.HasTrack)
{
<div class="player-dock">
<AudioPlayer @key="@($"{Session.SurahNumber}:{Session.AyahNumber}:{Session.AudioUrl}")"
Title="@Session.DisplayTitle"
AudioUrl="@Session.AudioUrl"
AudioKey="@($"{Session.SurahNumber}:{Session.AyahNumber}")"
InitialPosition="Session.PositionSeconds"
IsFavorite="Session.IsFavorite"
IsBookmarked="Session.IsBookmarked"
OnFavoriteToggle="Session.ToggleFavoriteAsync"
OnBookmarkToggle="Session.ToggleBookmarkAsync"
OnProgress="OnProgress"
OnTitleClick="GoToTrack" />
</div>
}
@code {
protected override void OnInitialized() => Session.Changed += OnChanged;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
await Session.EnsureLoadedAsync();
}
private async Task OnProgress(double position) => await Session.ReportPositionAsync(position);
private void GoToTrack()
{
if (Session.HasTrack)
Nav.NavigateTo(Session.ResumeHref);
}
private void OnChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => Session.Changed -= OnChanged;
}
@@ -0,0 +1,27 @@
@if (Variant == "lines")
{
<div class="skeleton-stack" aria-hidden="true">
@for (var i = 0; i < Lines; i++)
{
<div class="skeleton-line" style="@($"width:{(i % 2 == 0 ? 92 : 70)}%")"></div>
}
</div>
}
else if (Variant == "cards")
{
<div class="skeleton-stack" aria-hidden="true">
@for (var i = 0; i < Lines; i++)
{
<div class="skeleton-card"></div>
}
</div>
}
else
{
<div class="skeleton-block" aria-hidden="true"></div>
}
@code {
[Parameter] public string Variant { get; set; } = "block";
[Parameter] public int Lines { get; set; } = 4;
}
+23 -3
View File
@@ -2,7 +2,7 @@
@implements IDisposable @implements IDisposable
@inject AuthStateService Auth @inject AuthStateService Auth
<div class="app-shell @(sidebarOpen ? "sidebar-open" : null)"> <div class="app-shell @(sidebarOpen ? "sidebar-open" : null) @(hasDock ? "has-player-dock" : null)">
<header class="topbar"> <header class="topbar">
<button class="icon-btn" type="button" @onclick="ToggleSidebar" aria-label="منو"> <button class="icon-btn" type="button" @onclick="ToggleSidebar" aria-label="منو">
<span class="burger"></span> <span class="burger"></span>
@@ -20,23 +20,43 @@
<main class="content"> <main class="content">
@Body @Body
</main> </main>
<PlayerDock />
</div> </div>
@code { @code {
[Inject] private PlayerSessionService Session { get; set; } = default!;
private bool sidebarOpen; private bool sidebarOpen;
private bool hasDock;
protected override void OnInitialized() => Auth.Changed += OnAuthChanged; protected override void OnInitialized()
{
Auth.Changed += OnAuthChanged;
Session.Changed += OnSessionChanged;
hasDock = Session.HasTrack;
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (!firstRender) return; if (!firstRender) return;
await Auth.RefreshAsync(); await Auth.RefreshAsync();
await Session.EnsureLoadedAsync();
hasDock = Session.HasTrack;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private void OnAuthChanged() => InvokeAsync(StateHasChanged); private void OnAuthChanged() => InvokeAsync(StateHasChanged);
private void OnSessionChanged()
{
hasDock = Session.HasTrack;
InvokeAsync(StateHasChanged);
}
private void ToggleSidebar() => sidebarOpen = !sidebarOpen; private void ToggleSidebar() => sidebarOpen = !sidebarOpen;
private void CloseSidebar() => sidebarOpen = false; private void CloseSidebar() => sidebarOpen = false;
public void Dispose() => Auth.Changed -= OnAuthChanged; public void Dispose()
{
Auth.Changed -= OnAuthChanged;
Session.Changed -= OnSessionChanged;
}
} }
+1
View File
@@ -16,6 +16,7 @@
</div> </div>
} }
<NavLink class="side-item" href="/tafsir/ghatreh-chekani" @onclick="Navigate">فهرست سوره‌ها</NavLink>
<NavLink class="side-item" href="/favorites" @onclick="Navigate">علاقه‌مندی‌ها</NavLink> <NavLink class="side-item" href="/favorites" @onclick="Navigate">علاقه‌مندی‌ها</NavLink>
<NavLink class="side-item" href="/bookmarks" @onclick="Navigate">نشان‌شده‌ها</NavLink> <NavLink class="side-item" href="/bookmarks" @onclick="Navigate">نشان‌شده‌ها</NavLink>
<NavLink class="side-item" href="/about" @onclick="Navigate">درباره ما</NavLink> <NavLink class="side-item" href="/about" @onclick="Navigate">درباره ما</NavLink>
+21 -1
View File
@@ -1,12 +1,23 @@
@page "/" @page "/"
@inject TafsirApiClient Api @inject TafsirApiClient Api
@inject PlayerSessionService Player
@inject NavigationManager Nav
@implements IDisposable
<section class="home"> <section class="home">
<h1 class="sr-only">تفسیر قرآن</h1> <h1 class="sr-only">تفسیر قرآن</h1>
@if (Player.HasTrack)
{
<button type="button" class="resume-btn" @onclick="Resume">
<span class="resume-label">ادامه از آخرین مراجعه</span>
<span class="resume-meta">@Player.DisplayTitle</span>
</button>
}
@if (loading) @if (loading)
{ {
<p class="muted">در حال بارگذاری...</p> <SkeletonBlock Variant="cards" Lines="2" />
} }
else if (tafsirs.Count == 0) else if (tafsirs.Count == 0)
{ {
@@ -33,11 +44,14 @@
private List<TafsirDto> tafsirs = []; private List<TafsirDto> tafsirs = [];
private bool loading = true; private bool loading = true;
protected override void OnInitialized() => Player.Changed += OnPlayerChanged;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (!firstRender) return; if (!firstRender) return;
try try
{ {
await Player.EnsureLoadedAsync();
tafsirs = (await Api.GetTafsirsAsync()).ToList(); tafsirs = (await Api.GetTafsirsAsync()).ToList();
} }
catch catch
@@ -50,4 +64,10 @@
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
} }
private void Resume() => Nav.NavigateTo(Player.ResumeHref);
private void OnPlayerChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => Player.Changed -= OnPlayerChanged;
} }
+112 -90
View File
@@ -4,22 +4,34 @@
@inject TafsirApiClient Api @inject TafsirApiClient Api
@inject IDeviceIdStore DeviceStore @inject IDeviceIdStore DeviceStore
@inject NavigationManager Nav @inject NavigationManager Nav
@inject PlayerSessionService Player
<section class="tafsir-page"> <section class="tafsir-page">
<header class="page-header"> <header class="page-header page-header-row">
<h1>@title</h1> @if (loadingMeta)
<p class="muted">@author</p>
@if (view == View.Ayahs || view == View.Detail)
{ {
<button type="button" class="linkish" @onclick="BackToSurahs">← فهرست سوره‌ها</button> <SkeletonBlock Variant="lines" Lines="1" />
}
else
{
<h1 class="page-title-inline">
<span>@title</span>
@if (!string.IsNullOrWhiteSpace(author))
{
<span class="title-author">(@author)</span>
}
</h1>
<button type="button" class="back-btn" @onclick="GoBack" aria-label="بازگشت">←</button>
} }
</header> </header>
@if (loading) @if (view == View.Surahs)
{ {
<p class="muted">در حال بارگذاری...</p> @if (loadingSurahs)
{
<SkeletonBlock Variant="cards" Lines="8" />
} }
else if (view == View.Surahs) else
{ {
<div class="surah-grid"> <div class="surah-grid">
@foreach (var s in surahs) @foreach (var s in surahs)
@@ -39,9 +51,16 @@
} }
</div> </div>
} }
}
else if (view == View.Ayahs) else if (view == View.Ayahs)
{ {
<h2 class="section-title">سوره @selectedSurah?.NameFa</h2> <h2 class="section-title">سوره @(selectedSurah?.NameFa ?? "…")</h2>
@if (loadingAyahs)
{
<SkeletonBlock Variant="cards" Lines="10" />
}
else
{
<div class="ayah-list"> <div class="ayah-list">
@foreach (var a in ayahs) @foreach (var a in ayahs)
{ {
@@ -57,17 +76,25 @@
} }
</div> </div>
} }
else if (detail is not null) }
else if (view == View.Detail)
{ {
<div class="ayah-detail"> <div class="ayah-detail">
<div class="ayah-nav"> <div class="ayah-nav">
<button type="button" class="chip" disabled="@(detail.PrevAyah is null)" <button type="button" class="chip" disabled="@(detail?.PrevAyah is null)"
@onclick="() => OpenAyah(detail.PrevAyah!.Value)">آیه قبل</button> @onclick="() => GoPrevAyah()">آیه قبل</button>
<button type="button" class="chip" @onclick="() => OpenSurah(detail.SurahNumber)">فهرست آیات</button> <button type="button" class="chip" @onclick="() => OpenSurah(SurahNumber ?? 1)">فهرست آیات</button>
<button type="button" class="chip" disabled="@(detail.NextAyah is null)" <button type="button" class="chip" disabled="@(detail?.NextAyah is null)"
@onclick="() => OpenAyah(detail.NextAyah!.Value)">آیه بعد</button> @onclick="() => GoNextAyah()">آیه بعد</button>
</div> </div>
@if (loadingDetail || detail is null)
{
<SkeletonBlock Variant="lines" Lines="5" />
<SkeletonBlock Variant="cards" Lines="2" />
}
else
{
<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))
@@ -76,27 +103,16 @@
} }
</div> </div>
<AudioPlayer @key="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
Title="@($"{detail.SurahNameFa} — آیه {detail.AyahNumber}")"
AudioUrl="@detail.AudioUrl"
AudioKey="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
InitialPosition="initialPosition"
IsFavorite="isFavorite"
IsBookmarked="isBookmarked"
OnFavoriteToggle="ToggleFavorite"
OnBookmarkToggle="ToggleBookmark"
OnProgress="SaveProgress" />
@if (!string.IsNullOrWhiteSpace(detail.NotesHtml)) @if (!string.IsNullOrWhiteSpace(detail.NotesHtml))
{ {
<details class="content-block" open> <details class="content-block">
<summary>نکته‌ها</summary> <summary>نکته‌ها</summary>
<div class="html-body">@((MarkupString)detail.NotesHtml)</div> <div class="html-body">@((MarkupString)detail.NotesHtml)</div>
</details> </details>
} }
@if (!string.IsNullOrWhiteSpace(detail.MessagesHtml)) @if (!string.IsNullOrWhiteSpace(detail.MessagesHtml))
{ {
<details class="content-block" open> <details class="content-block">
<summary>پیام‌ها</summary> <summary>پیام‌ها</summary>
<div class="html-body">@((MarkupString)detail.MessagesHtml)</div> <div class="html-body">@((MarkupString)detail.MessagesHtml)</div>
</details> </details>
@@ -110,6 +126,7 @@
</div> </div>
</details> </details>
} }
}
</div> </div>
} }
</section> </section>
@@ -124,16 +141,16 @@
private string title = "تفسیر"; private string title = "تفسیر";
private string author = ""; private string author = "";
private Guid tafsirId; private Guid tafsirId;
private bool loading = true; private bool loadingMeta = true;
private bool loadingSurahs;
private bool loadingAyahs;
private bool loadingDetail;
private string? routeKey; private string? routeKey;
private View view = View.Surahs; private View view = View.Surahs;
private List<SurahListItemDto> surahs = []; private List<SurahListItemDto> surahs = [];
private List<AyahListItemDto> ayahs = []; private List<AyahListItemDto> ayahs = [];
private SurahListItemDto? selectedSurah; private SurahListItemDto? selectedSurah;
private AyahDetailDto? detail; private AyahDetailDto? detail;
private double initialPosition;
private bool isFavorite;
private bool isBookmarked;
private string deviceId = ""; private string deviceId = "";
private bool ready; private bool ready;
@@ -157,7 +174,15 @@
if (routeKey == key) return; if (routeKey == key) return;
routeKey = key; routeKey = key;
loading = true; if (AyahNumber is not null && SurahNumber is not null) view = View.Detail;
else if (SurahNumber is not null) view = View.Ayahs;
else view = View.Surahs;
loadingMeta = true;
loadingSurahs = view == View.Surahs;
loadingAyahs = view == View.Ayahs;
loadingDetail = view == View.Detail;
detail = null;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
try try
@@ -166,37 +191,39 @@
deviceId = await DeviceStore.GetOrCreateAsync(); deviceId = await DeviceStore.GetOrCreateAsync();
var tafsirs = await Api.GetTafsirsAsync(); var tafsirs = await Api.GetTafsirsAsync();
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == slug); var tafsir = tafsirs.FirstOrDefault(t => t.Slug == Slug);
title = tafsir?.Title ?? "تفسیر"; title = tafsir?.Title ?? "تفسیر";
author = tafsir?.Author ?? ""; author = tafsir?.Author ?? "";
tafsirId = tafsir?.Id ?? Guid.Empty; tafsirId = tafsir?.Id ?? Guid.Empty;
loadingMeta = false;
await InvokeAsync(StateHasChanged);
if (AyahNumber is int a && SurahNumber is int s) if (view == View.Detail && SurahNumber is int s && AyahNumber is int a)
{
view = View.Detail;
await LoadDetail(s, a); await LoadDetail(s, a);
} else if (view == View.Ayahs && SurahNumber is int sn)
else if (SurahNumber is int sn)
{
view = View.Ayahs;
await LoadAyahs(sn); await LoadAyahs(sn);
}
else else
{ await LoadSurahs();
view = View.Surahs;
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
}
} }
finally finally
{ {
loading = false; loadingMeta = false;
loadingSurahs = false;
loadingAyahs = false;
loadingDetail = false;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
} }
private string slug => Slug; private void GoBack()
{
private void BackToSurahs() => Nav.NavigateTo($"/tafsir/{Slug}"); if (view == View.Detail && SurahNumber is int s)
Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}");
else if (view == View.Ayahs)
Nav.NavigateTo($"/tafsir/{Slug}");
else
Nav.NavigateTo("/");
}
private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}"); private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}");
@@ -206,30 +233,52 @@
Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}"); Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}");
} }
private void GoPrevAyah()
{
if (detail?.PrevAyah is int p) OpenAyah(p);
}
private void GoNextAyah()
{
if (detail?.NextAyah is int n) OpenAyah(n);
}
private async Task LoadSurahs()
{
loadingSurahs = true;
await InvokeAsync(StateHasChanged);
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
loadingSurahs = false;
}
private async Task LoadAyahs(int surahNumber) private async Task LoadAyahs(int surahNumber)
{ {
surahs = surahs.Count > 0 ? surahs : (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList(); loadingAyahs = true;
await InvokeAsync(StateHasChanged);
if (surahs.Count == 0)
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
selectedSurah = surahs.FirstOrDefault(x => x.Number == surahNumber); selectedSurah = surahs.FirstOrDefault(x => x.Number == surahNumber);
ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList(); ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList();
loadingAyahs = false;
} }
private async Task LoadDetail(int surahNumber, int ayahNumber) private async Task LoadDetail(int surahNumber, int ayahNumber)
{ {
loadingDetail = true;
await InvokeAsync(StateHasChanged);
detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber); detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber);
loadingDetail = false;
await InvokeAsync(StateHasChanged);
if (detail is null) return; if (detail is null) return;
var progress = await Api.GetProgressAsync(deviceId);
initialPosition = progress is not null
&& progress.SurahNumber == surahNumber
&& progress.AyahNumber == ayahNumber
? progress.PositionSeconds
: 0;
var favorites = await Api.GetFavoritesAsync(deviceId);
var bookmarks = await Api.GetAyahBookmarksAsync(deviceId);
isFavorite = favorites.Any(f => f.TafsirId == detail.TafsirId && f.SurahNumber == surahNumber && f.AyahNumber == ayahNumber);
isBookmarked = bookmarks.Any(b => b.TafsirId == detail.TafsirId && b.SurahNumber == surahNumber && b.AyahNumber == ayahNumber);
tafsirId = detail.TafsirId; tafsirId = detail.TafsirId;
var resumePos = Player.HasTrack
&& Player.SurahNumber == surahNumber
&& Player.AyahNumber == ayahNumber
? Player.PositionSeconds
: 0;
await Player.SetTrackAsync(detail, resumePos);
} }
private async Task ToggleSurahBookmark(SurahListItemDto s) private async Task ToggleSurahBookmark(SurahListItemDto s)
@@ -241,31 +290,4 @@
await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId)); await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId));
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId)).ToList(); surahs = (await Api.GetSurahsAsync(deviceId, tafsirId)).ToList();
} }
private async Task ToggleFavorite()
{
if (detail is null) return;
if (isFavorite)
await Api.RemoveFavoriteAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else
await Api.AddFavoriteAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
isFavorite = !isFavorite;
}
private async Task ToggleBookmark()
{
if (detail is null) return;
if (isBookmarked)
await Api.RemoveAyahBookmarkAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else
await Api.AddAyahBookmarkAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
isBookmarked = !isBookmarked;
}
private async Task SaveProgress(double position)
{
if (detail is null) return;
await Api.SaveProgressAsync(new UpsertProgressRequest(
detail.TafsirId, detail.SurahNumber, detail.AyahNumber, position, deviceId));
}
} }
@@ -0,0 +1,163 @@
using TafsirQuran.Shared.Dtos;
using TafsirQuran.Shared.Services;
namespace TafsirQuran.UI.Services;
public sealed class PlayerSessionService
{
private readonly TafsirApiClient _api;
private readonly IDeviceIdStore _devices;
private bool _loaded;
private string _deviceId = "";
public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices)
{
_api = api;
_devices = devices;
}
public event Action? Changed;
public bool HasTrack => SurahNumber > 0 && AyahNumber > 0 && !string.IsNullOrWhiteSpace(TafsirSlug);
public Guid TafsirId { get; private set; }
public string TafsirSlug { get; private set; } = "ghatreh-chekani";
public int SurahNumber { get; private set; }
public int AyahNumber { get; private set; }
public string SurahNameFa { get; private set; } = "";
public string? AudioUrl { get; private set; }
public double PositionSeconds { get; private set; }
public bool IsFavorite { get; private set; }
public bool IsBookmarked { get; private set; }
public string DisplayTitle =>
string.IsNullOrWhiteSpace(SurahNameFa)
? $"آیه {AyahNumber}"
: $"{SurahNameFa} - آیه {AyahNumber}";
public string ResumeHref =>
HasTrack ? $"/tafsir/{TafsirSlug}/surah/{SurahNumber}/ayah/{AyahNumber}" : "/";
public async Task EnsureLoadedAsync()
{
if (_loaded) return;
_deviceId = await _devices.GetOrCreateAsync();
try
{
var progress = await _api.GetProgressAsync(_deviceId);
if (progress is not null)
{
TafsirId = progress.TafsirId;
SurahNumber = progress.SurahNumber;
AyahNumber = progress.AyahNumber;
SurahNameFa = progress.SurahNameFa ?? "";
PositionSeconds = progress.PositionSeconds;
var tafsirs = await _api.GetTafsirsAsync();
var tafsir = tafsirs.FirstOrDefault(t => t.Id == progress.TafsirId);
TafsirSlug = tafsir?.Slug ?? "ghatreh-chekani";
var detail = await _api.GetAyahAsync(TafsirSlug, SurahNumber, AyahNumber);
if (detail is not null)
{
AudioUrl = detail.AudioUrl;
SurahNameFa = detail.SurahNameFa;
await SyncFlagsAsync();
}
}
}
catch
{
/* ignore bootstrap failures */
}
_loaded = true;
Notify();
}
public async Task SetTrackAsync(AyahDetailDto detail, double position = 0)
{
await EnsureDeviceAsync();
TafsirId = detail.TafsirId;
TafsirSlug = detail.TafsirSlug;
SurahNumber = detail.SurahNumber;
AyahNumber = detail.AyahNumber;
SurahNameFa = detail.SurahNameFa;
AudioUrl = detail.AudioUrl;
PositionSeconds = position;
await SyncFlagsAsync();
Notify();
await PersistAsync(position);
}
public async Task ReportPositionAsync(double position)
{
if (!HasTrack) return;
PositionSeconds = position;
await PersistAsync(position);
Notify();
}
public async Task ToggleFavoriteAsync()
{
if (!HasTrack) return;
await EnsureDeviceAsync();
if (IsFavorite)
await _api.RemoveFavoriteAsync(TafsirId, SurahNumber, AyahNumber, _deviceId);
else
await _api.AddFavoriteAsync(new ToggleAyahRequest(TafsirId, SurahNumber, AyahNumber, _deviceId));
IsFavorite = !IsFavorite;
Notify();
}
public async Task ToggleBookmarkAsync()
{
if (!HasTrack) return;
await EnsureDeviceAsync();
if (IsBookmarked)
await _api.RemoveAyahBookmarkAsync(TafsirId, SurahNumber, AyahNumber, _deviceId);
else
await _api.AddAyahBookmarkAsync(new ToggleAyahRequest(TafsirId, SurahNumber, AyahNumber, _deviceId));
IsBookmarked = !IsBookmarked;
Notify();
}
private async Task SyncFlagsAsync()
{
try
{
var favorites = await _api.GetFavoritesAsync(_deviceId);
var bookmarks = await _api.GetAyahBookmarksAsync(_deviceId);
IsFavorite = favorites.Any(f =>
f.TafsirId == TafsirId && f.SurahNumber == SurahNumber && f.AyahNumber == AyahNumber);
IsBookmarked = bookmarks.Any(b =>
b.TafsirId == TafsirId && b.SurahNumber == SurahNumber && b.AyahNumber == AyahNumber);
}
catch
{
/* ignore */
}
}
private async Task PersistAsync(double position)
{
try
{
await EnsureDeviceAsync();
await _api.SaveProgressAsync(new UpsertProgressRequest(
TafsirId, SurahNumber, AyahNumber, position, _deviceId));
}
catch
{
/* ignore */
}
}
private async Task EnsureDeviceAsync()
{
if (string.IsNullOrWhiteSpace(_deviceId))
_deviceId = await _devices.GetOrCreateAsync();
}
private void Notify() => Changed?.Invoke();
}
@@ -12,6 +12,7 @@ public static class UiServiceCollectionExtensions
services.AddScoped<IDeviceIdStore, BrowserDeviceIdStore>(); services.AddScoped<IDeviceIdStore, BrowserDeviceIdStore>();
services.AddScoped<IAuthTokenStore, BrowserAuthTokenStore>(); services.AddScoped<IAuthTokenStore, BrowserAuthTokenStore>();
services.AddScoped<AuthStateService>(); services.AddScoped<AuthStateService>();
services.AddScoped<PlayerSessionService>();
services.AddScoped(sp => services.AddScoped(sp =>
{ {
var http = new HttpClient { BaseAddress = new Uri(normalized) }; var http = new HttpClient { BaseAddress = new Uri(normalized) };
+90 -4
View File
@@ -118,6 +118,7 @@ button, input { font: inherit; }
.user-chip { padding: 0.5rem 0.9rem; color: #c7d8ce; font-size: 0.9rem; } .user-chip { padding: 0.5rem 0.9rem; color: #c7d8ce; font-size: 0.9rem; }
.content { padding: 1.25rem 1rem 2.5rem; max-width: 980px; margin: 0 auto; } .content { padding: 1.25rem 1rem 2.5rem; max-width: 980px; margin: 0 auto; }
.app-shell.has-player-dock .content { padding-bottom: 8.5rem; }
.home { padding-top: 1.5rem; } .home { padding-top: 1.5rem; }
.tile-grid { display: grid; gap: 1rem; } .tile-grid { display: grid; gap: 1rem; }
@@ -181,11 +182,18 @@ button, input { font: inherit; }
.ayah-block { margin-bottom: 1rem; } .ayah-block { margin-bottom: 1rem; }
.ayah-ar { .ayah-ar {
font-family: var(--font-ar); font-family: var(--font-ar);
font-size: clamp(1.4rem, 3vw, 1.9rem); font-size: clamp(1.75rem, 4.2vw, 2.35rem);
line-height: 2; line-height: 2.05;
margin: 0 0 0.5rem; margin: 0 0 0.65rem;
color: #14261e;
font-weight: 700;
}
.ayah-fa {
margin: 0;
font-size: clamp(1.05rem, 2.6vw, 1.28rem);
line-height: 1.95;
color: #3a4a42;
} }
.ayah-fa { margin: 0; color: var(--muted); }
.audio-player { display: flex; flex-direction: column; gap: 0.85rem; } .audio-player { display: flex; flex-direction: column; gap: 0.85rem; }
.player-title { font-weight: 600; } .player-title { font-weight: 600; }
@@ -272,3 +280,81 @@ button, input { font: inherit; }
padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line);
} }
.chip:disabled { opacity: 0.45; cursor: default; } .chip:disabled { opacity: 0.45; cursor: default; }
.page-header-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
margin-bottom: 1rem;
}
.page-title-inline {
margin: 0; font-size: clamp(1.15rem, 3.5vw, 1.45rem); font-weight: 700;
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem;
}
.title-author { font-weight: 500; color: var(--muted); font-size: 0.92em; }
.back-btn {
flex-shrink: 0; width: 42px; height: 42px; border-radius: 12px;
border: 1px solid var(--line); background: #fff; cursor: pointer;
font-size: 1.25rem; line-height: 1; color: var(--brand-deep);
}
.skeleton-stack { display: grid; gap: 0.65rem; margin: 0.5rem 0 1rem; }
.skeleton-line, .skeleton-card, .skeleton-block {
background: linear-gradient(90deg, rgba(28,43,36,0.07), rgba(28,43,36,0.14), rgba(28,43,36,0.07));
background-size: 200% 100%;
animation: skeleton-shine 1.2s ease-in-out infinite;
border-radius: 10px;
}
.skeleton-line { height: 14px; }
.skeleton-card { height: 64px; border-radius: 14px; }
.skeleton-block { height: 120px; }
@keyframes skeleton-shine {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.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);
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);
background: #fff;
border-radius: 18px;
padding: 0.75rem 0.9rem 0.85rem;
direction: rtl;
text-align: right;
}
.dock-top {
display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem;
margin-bottom: 0.55rem;
}
.dock-title {
border: 0; background: transparent; padding: 0; cursor: pointer;
font-weight: 700; font-size: 0.98rem; color: var(--ink); text-align: right;
}
.dock-actions { display: flex; gap: 0.35rem; 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;
}
.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;
}
.dock-empty { margin: 0; font-size: 0.9rem; }
.resume-btn {
width: 100%; margin: 0 0 1rem; padding: 1rem 1.1rem;
border: 1px solid rgba(31,107,74,0.35); border-radius: 18px;
background: linear-gradient(135deg, rgba(31,107,74,0.12), rgba(31,107,74,0.04));
cursor: pointer; text-align: right;
display: flex; flex-direction: column; gap: 0.25rem;
}
.resume-label { font-weight: 700; color: var(--brand-deep); }
.resume-meta { color: var(--muted); font-size: 0.9rem; }
@@ -1,5 +1,5 @@
// Cache static WASM assets only — never HTML navigations. // Cache static WASM assets only — never HTML navigations.
const CACHE = 'tafsirquran-wasm-v2'; const CACHE = 'tafsirquran-wasm-v3';
self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
self.addEventListener('activate', event => event.waitUntil( self.addEventListener('activate', event => event.waitUntil(