Add fullscreen player sheet and surah ayah TOC.
build-and-deploy / deploy (push) Successful in 4m5s

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:
masoodafar-web
2026-10-10 03:05:07 +03:30
parent fd8caa3b7c
commit a63d482e15
6 changed files with 563 additions and 37 deletions
+134 -30
View File
@@ -2,43 +2,128 @@
@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")"
<div class="dock-top" dir="rtl"> @ref="root"
<button type="button" class="dock-title" @onclick="OnTitleClick">@Title</button> @onclick="OnRootClick">
<div class="dock-actions"> @if (Layout == PlayerLayout.Dock)
@if (ShowFavorite)
{
<button type="button" class="icon-chip @(IsFavorite ? "on" : null)" title="علاقه"
@onclick="OnFavoriteToggle" aria-label="علاقه">♥</button>
}
@if (ShowBookmark)
{
<button type="button" class="icon-chip @(IsBookmarked ? "on" : null)" title="نشان"
@onclick="OnBookmarkToggle" aria-label="نشان">⚑</button>
}
</div>
</div>
@if (!string.IsNullOrWhiteSpace(AudioUrl))
{ {
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio> <div class="dock-top" dir="rtl">
<div class="player-controls ltr-controls"> <button type="button" class="dock-title" @onclick="OnExpandClick" @onclick:stopPropagation="true">@Title</button>
<button type="button" class="play-btn" @onclick="TogglePlay" aria-label="پخش"> <div class="dock-actions" @onclick:stopPropagation="true">
@(playing ? "❚❚" : "▶") @if (ShowFavorite)
</button> {
<input class="seek" type="range" min="0" max="@Math.Max(duration, 0.1).ToString("0.###")" step="0.1" <button type="button" class="icon-chip @(IsFavorite ? "on" : null)" title="علاقه"
value="@current.ToString("0.###")" @oninput="OnSeek" dir="ltr" /> @onclick="OnFavoriteToggle" aria-label="علاقه">♥</button>
<div class="time">@Format(current) / @Format(duration)</div> }
@if (ShowBookmark)
{
<button type="button" class="icon-chip @(IsBookmarked ? "on" : null)" title="نشان"
@onclick="OnBookmarkToggle" aria-label="نشان">⚑</button>
}
</div>
</div> </div>
@if (HasAudio)
{
<div class="player-controls ltr-controls" @onclick:stopPropagation="true">
<button type="button" class="play-btn" @onclick="TogglePlay" aria-label="پخش">
@(playing ? "❚❚" : "▶")
</button>
<input class="seek" type="range" min="0" max="@SeekMax" step="0.1"
value="@current.ToString("0.###")" @oninput="OnSeek" dir="ltr" />
<div class="time">@Format(current) / @Format(duration)</div>
</div>
}
else
{
<p class="muted dock-empty" dir="rtl">صوت این آیه در دسترس نیست</p>
}
} }
else else
{ {
<p class="muted dock-empty" dir="rtl">صوت این آیه در دسترس نیست</p> <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;
+16 -5
View File
@@ -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,8 +45,9 @@
private void GoToTrack() private void GoToTrack()
{ {
if (Session.HasTrack) if (!Session.HasTrack) return;
Nav.NavigateTo(Session.ResumeHref); Session.Collapse();
Nav.NavigateTo(Session.ResumeHref);
} }
private void OnChanged() => InvokeAsync(StateHasChanged); private void OnChanged() => InvokeAsync(StateHasChanged);
@@ -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
+175
View File
@@ -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;
}
+19
View File
@@ -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
};
} }
}; };