From a63d482e15bf11f5c5782bb4475b980755e69735 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Sat, 10 Oct 2026 03:05:07 +0330 Subject: [PATCH] Add fullscreen player sheet and surah ayah TOC. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Components/AudioPlayer.razor | 164 +++++++++++++--- .../Components/PlayerDock.razor | 21 ++- src/TafsirQuran.UI/Components/PlayerToc.razor | 118 ++++++++++++ .../Services/PlayerSessionService.cs | 103 ++++++++++- src/TafsirQuran.UI/wwwroot/css/app.css | 175 ++++++++++++++++++ src/TafsirQuran.UI/wwwroot/js/app.js | 19 ++ 6 files changed, 563 insertions(+), 37 deletions(-) create mode 100644 src/TafsirQuran.UI/Components/PlayerToc.razor diff --git a/src/TafsirQuran.UI/Components/AudioPlayer.razor b/src/TafsirQuran.UI/Components/AudioPlayer.razor index bf1b7da..d1be68c 100644 --- a/src/TafsirQuran.UI/Components/AudioPlayer.razor +++ b/src/TafsirQuran.UI/Components/AudioPlayer.razor @@ -2,43 +2,128 @@ @inject ApiOptions ApiOptions @implements IAsyncDisposable -
-
- -
- @if (ShowFavorite) - { - - } - @if (ShowBookmark) - { - - } -
-
- - @if (!string.IsNullOrWhiteSpace(AudioUrl)) +
+ @if (Layout == PlayerLayout.Dock) { - -
- - -
@Format(current) / @Format(duration)
+
+ +
+ @if (ShowFavorite) + { + + } + @if (ShowBookmark) + { + + } +
+ + @if (HasAudio) + { +
+ + +
@Format(current) / @Format(duration)
+
+ } + else + { +

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

+ } } else { -

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

+
+ +
@HeaderTitle
+ +
+ + + +
+
@SurahLabel — آیه @AyahNumber
+ @if (SurahAyahCount > 0) + { +
@AyahNumber / @SurahAyahCount
+ } +
+ +
+ + @if (ShowFavorite) + { + + } + @if (ShowBookmark) + { + + } +
+ + @if (HasAudio) + { +
+ +
+ @Format(current) + @Format(Remaining) باقی مانده + @Format(duration) +
+
+ +
+ + + +
+ } + else + { +

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

+ } + } + + @if (HasAudio) + { + }
@code { + public enum PlayerLayout { Dock, Expanded } + [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 AudioKey { get; set; } = ""; [Parameter] public double InitialPosition { get; set; } @@ -49,7 +134,10 @@ [Parameter] public EventCallback OnFavoriteToggle { get; set; } [Parameter] public EventCallback OnBookmarkToggle { get; set; } [Parameter] public EventCallback 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 OnPlayingChanged { get; set; } private ElementReference audioEl; @@ -62,11 +150,14 @@ private bool bound; private double lastReported = -1; + private bool HasAudio => !string.IsNullOrWhiteSpace(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) { - if (string.IsNullOrWhiteSpace(AudioUrl)) return; + if (!HasAudio) return; if (!bound) { @@ -94,12 +185,26 @@ 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() { playing = await Js.InvokeAsync("tqAudio.toggle", audioEl); await OnPlayingChanged.InvokeAsync(playing); } + private async Task SeekBy(double delta) + { + current = await Js.InvokeAsync("tqAudio.seekBy", audioEl, delta); + await OnProgress.InvokeAsync(current); + } + private async Task OnSeek(ChangeEventArgs e) { if (double.TryParse(e.Value?.ToString(), out var v)) @@ -124,7 +229,6 @@ await InvokeAsync(StateHasChanged); - // Throttle UI→session position reports (~1s) if (isPlaying && Math.Abs(time - lastReported) >= 0.9) { lastReported = time; diff --git a/src/TafsirQuran.UI/Components/PlayerDock.razor b/src/TafsirQuran.UI/Components/PlayerDock.razor index 3c6503c..e9d532b 100644 --- a/src/TafsirQuran.UI/Components/PlayerDock.razor +++ b/src/TafsirQuran.UI/Components/PlayerDock.razor @@ -4,9 +4,14 @@ @if (Session.HasTrack) { -
- + + OnExpand="Session.Expand" + OnCollapse="Session.Collapse" + OnOpenToc="Session.OpenToc" + OnOpenText="GoToTrack" />
+ + } @code { @@ -35,8 +45,9 @@ private void GoToTrack() { - if (Session.HasTrack) - Nav.NavigateTo(Session.ResumeHref); + if (!Session.HasTrack) return; + Session.Collapse(); + Nav.NavigateTo(Session.ResumeHref); } private void OnChanged() => InvokeAsync(StateHasChanged); diff --git a/src/TafsirQuran.UI/Components/PlayerToc.razor b/src/TafsirQuran.UI/Components/PlayerToc.razor new file mode 100644 index 0000000..5a3fd89 --- /dev/null +++ b/src/TafsirQuran.UI/Components/PlayerToc.razor @@ -0,0 +1,118 @@ +@inject PlayerSessionService Session +@implements IDisposable + +@if (Session.IsTocOpen && Session.HasTrack) +{ +
+
+ +
+ سوره @Session.SurahNameFa + @Session.TafsirTitle +
+
+ + @if (loading) + { +
+
+
+
+
+ } + else if (ayahs.Count == 0) + { +

آیه‌ای یافت نشد

+ } + else + { +
    + @foreach (var a in ayahs) + { + var active = a.AyahNumber == Session.AyahNumber; +
  • + +
  • + } +
+ } +
+} + +@code { + private List 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; +} diff --git a/src/TafsirQuran.UI/Services/PlayerSessionService.cs b/src/TafsirQuran.UI/Services/PlayerSessionService.cs index b68aae5..44ca02a 100644 --- a/src/TafsirQuran.UI/Services/PlayerSessionService.cs +++ b/src/TafsirQuran.UI/Services/PlayerSessionService.cs @@ -15,6 +15,7 @@ public sealed class PlayerSessionService private double _playedAccumulated; private bool _persistedForTrack; private DateTimeOffset _lastPersistAt = DateTimeOffset.MinValue; + private int _ayahCountCachedForSurah = -1; public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices) { @@ -28,6 +29,7 @@ public sealed class PlayerSessionService public Guid TafsirId { get; private set; } public string TafsirSlug { get; private set; } = "ghatreh-chekani"; + public string TafsirTitle { get; private set; } = "تفسیر قطره‌چکانی"; public int SurahNumber { get; private set; } public int AyahNumber { get; private set; } public string SurahNameFa { get; private set; } = ""; @@ -35,6 +37,9 @@ public sealed class PlayerSessionService public double PositionSeconds { get; private set; } public bool IsFavorite { 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 => string.IsNullOrWhiteSpace(SurahNameFa) @@ -63,6 +68,7 @@ public sealed class PlayerSessionService var tafsirs = await _api.GetTafsirsAsync(); var tafsir = tafsirs.FirstOrDefault(t => t.Id == progress.TafsirId); TafsirSlug = tafsir?.Slug ?? "ghatreh-chekani"; + TafsirTitle = tafsir?.Title ?? TafsirTitle; var detail = await _api.GetAyahAsync(TafsirSlug, SurahNumber, AyahNumber); if (detail is not null) @@ -70,6 +76,7 @@ public sealed class PlayerSessionService AudioUrl = detail.AudioUrl; SurahNameFa = detail.SurahNameFa; await SyncFlagsAsync(); + await RefreshSurahAyahCountAsync(); } } } @@ -88,6 +95,7 @@ public sealed class PlayerSessionService var same = TafsirId == detail.TafsirId && SurahNumber == detail.SurahNumber && AyahNumber == detail.AyahNumber; + var sameSurah = TafsirId == detail.TafsirId && SurahNumber == detail.SurahNumber; TafsirId = detail.TafsirId; TafsirSlug = detail.TafsirSlug; @@ -104,9 +112,71 @@ public sealed class PlayerSessionService _persistedForTrack = false; } + if (!sameSurah) + _ayahCountCachedForSurah = -1; + + await EnsureTafsirTitleAsync(); await SyncFlagsAsync(); + if (_ayahCountCachedForSurah != SurahNumber) + await RefreshSurahAyahCountAsync(); 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> GetCurrentSurahAyahsAsync() + { + await EnsureDeviceAsync(); + if (!HasTrack) return Array.Empty(); + var list = await _api.GetAyahsAsync(TafsirSlug, SurahNumber, _deviceId); + SurahAyahCount = list.Count; + _ayahCountCachedForSurah = SurahNumber; + return list; } public void MarkPlaying(bool isPlaying) @@ -131,7 +201,6 @@ public sealed class PlayerSessionService 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(); @@ -175,6 +244,36 @@ public sealed class PlayerSessionService 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() { try diff --git a/src/TafsirQuran.UI/wwwroot/css/app.css b/src/TafsirQuran.UI/wwwroot/css/app.css index 87d928a..70fc59c 100644 --- a/src/TafsirQuran.UI/wwwroot/css/app.css +++ b/src/TafsirQuran.UI/wwwroot/css/app.css @@ -368,3 +368,178 @@ button, input { font: inherit; } } .resume-label { font-weight: 700; color: var(--brand-deep); } .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; +} diff --git a/src/TafsirQuran.UI/wwwroot/js/app.js b/src/TafsirQuran.UI/wwwroot/js/app.js index 811280c..0fd30fd 100644 --- a/src/TafsirQuran.UI/wwwroot/js/app.js +++ b/src/TafsirQuran.UI/wwwroot/js/app.js @@ -59,5 +59,24 @@ window.tqAudio = { seek: (audio, value) => { if (!audio) return; 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 + }; } };