Polish player chrome: left back, right menu, swipe-down dismiss.
build-and-deploy / deploy (push) Successful in 4m32s

Unify square icon buttons and close the expanded sheet with a real downward drag instead of a chevron control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masoodafar-web
2026-10-10 03:29:50 +03:30
parent 0cddd0f59a
commit 27a47a545e
4 changed files with 160 additions and 32 deletions
@@ -41,8 +41,8 @@
} }
else else
{ {
<div class="sheet-header" dir="rtl" @onclick:stopPropagation="true"> <div class="sheet-header" @onclick:stopPropagation="true">
<button type="button" class="sheet-icon-btn" @onclick="OnCollapseClick" aria-label="بستن" title="بستن">∨</button> <span class="sheet-header-spacer" aria-hidden="true"></span>
<div class="sheet-header-title">@HeaderTitle</div> <div class="sheet-header-title">@HeaderTitle</div>
<button type="button" class="sheet-icon-btn" @onclick="OnOpenTextClick" aria-label="متن آیه" title="متن آیه">☰</button> <button type="button" class="sheet-icon-btn" @onclick="OnOpenTextClick" aria-label="متن آیه" title="متن آیه">☰</button>
</div> </div>
@@ -147,6 +147,8 @@
private double duration; private double duration;
private string boundKey = ""; private string boundKey = "";
private bool bound; private bool bound;
private bool sheetBound;
private PlayerLayout lastSheetLayout = PlayerLayout.Dock;
private double lastReported = -1; private double lastReported = -1;
private bool HasAudio => !string.IsNullOrWhiteSpace(AudioUrl); private bool HasAudio => !string.IsNullOrWhiteSpace(AudioUrl);
@@ -156,11 +158,27 @@
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
self ??= DotNetObjectReference.Create(this);
if (Layout == PlayerLayout.Expanded)
{
if (!sheetBound || lastSheetLayout != PlayerLayout.Expanded)
{
await Js.InvokeVoidAsync("tqSheet.bindDismiss", root, self, "OnSwipeDismiss");
sheetBound = true;
}
}
else if (sheetBound)
{
await Js.InvokeVoidAsync("tqSheet.unbindDismiss", root);
sheetBound = false;
}
lastSheetLayout = Layout;
if (!HasAudio) return; if (!HasAudio) return;
if (!bound) if (!bound)
{ {
self = DotNetObjectReference.Create(this);
await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self); await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self);
bound = true; bound = true;
} }
@@ -188,10 +206,12 @@
Layout == PlayerLayout.Dock ? OnExpand.InvokeAsync() : Task.CompletedTask; Layout == PlayerLayout.Dock ? OnExpand.InvokeAsync() : Task.CompletedTask;
private Task OnExpandClick() => OnExpand.InvokeAsync(); private Task OnExpandClick() => OnExpand.InvokeAsync();
private Task OnCollapseClick() => OnCollapse.InvokeAsync();
private Task OnOpenTocClick() => OnOpenToc.InvokeAsync(); private Task OnOpenTocClick() => OnOpenToc.InvokeAsync();
private Task OnOpenTextClick() => OnOpenText.InvokeAsync(); private Task OnOpenTextClick() => OnOpenText.InvokeAsync();
[JSInvokable]
public Task OnSwipeDismiss() => OnCollapse.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);
@@ -260,6 +280,8 @@
{ {
try try
{ {
if (sheetBound)
await Js.InvokeVoidAsync("tqSheet.unbindDismiss", root);
if (bound) if (bound)
await Js.InvokeVoidAsync("tqAudio.unbind", audioEl); await Js.InvokeVoidAsync("tqAudio.unbind", audioEl);
} }
@@ -5,7 +5,7 @@
{ {
<div class="toc-sheet" dir="rtl" @key="tocKey"> <div class="toc-sheet" dir="rtl" @key="tocKey">
<div class="toc-header"> <div class="toc-header">
<button type="button" class="sheet-icon-btn" @onclick="Close" aria-label="بازگشت">›</button> <button type="button" class="sheet-icon-btn toc-back" @onclick="Close" aria-label="بازگشت">‹</button>
<div class="toc-header-text"> <div class="toc-header-text">
<strong>سوره @Session.SurahNameFa</strong> <strong>سوره @Session.SurahNameFa</strong>
<span class="muted">@Session.TafsirTitle</span> <span class="muted">@Session.TafsirTitle</span>
+31 -27
View File
@@ -290,11 +290,25 @@ button, input { font: inherit; }
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem;
} }
.title-author { font-weight: 500; color: var(--muted); font-size: 0.92em; } .title-author { font-weight: 500; color: var(--muted); font-size: 0.92em; }
.back-btn { .back-btn,
flex-shrink: 0; width: 52px; height: 52px; border-radius: 14px; .sheet-icon-btn,
border: 1px solid var(--line); background: #fff; cursor: pointer; .icon-chip,
font-size: 1.6rem; line-height: 1; color: var(--brand-deep); .sheet-action-icon {
flex-shrink: 0;
width: 44px; height: 44px; border-radius: 12px;
border: 1px solid rgba(28, 43, 36, 0.14);
background: #fff;
box-shadow: 0 2px 8px rgba(28, 43, 36, 0.08);
cursor: pointer;
color: var(--brand-deep);
display: grid; place-items: center; display: grid; place-items: center;
font-size: 1.35rem; line-height: 1;
}
.back-btn { width: 52px; height: 52px; border-radius: 14px; font-size: 1.6rem; }
.icon-chip.on,
.sheet-action.on .sheet-action-icon {
background: var(--brand); color: #fff; border-color: var(--brand);
box-shadow: 0 2px 10px rgba(31, 107, 74, 0.28);
} }
.ayah-page-title { .ayah-page-title {
margin: 0 0 0.85rem; font-size: 1.1rem; font-weight: 700; color: var(--brand-deep); margin: 0 0 0.85rem; font-size: 1.1rem; font-weight: 700; color: var(--brand-deep);
@@ -341,13 +355,6 @@ button, input { font: inherit; }
font-weight: 700; font-size: 1.05rem; color: var(--ink); text-align: right; font-weight: 700; font-size: 1.05rem; color: var(--ink); text-align: right;
} }
.dock-actions { display: flex; gap: 0.45rem; flex-shrink: 0; } .dock-actions { display: flex; gap: 0.45rem; flex-shrink: 0; }
.icon-chip {
width: 44px; height: 44px; border-radius: 12px;
border: 1px solid rgba(28, 43, 36, 0.22); background: #f3f6f4; cursor: pointer;
color: #1c2b24; font-size: 1.35rem;
display: grid; place-items: center;
}
.icon-chip.on { background: var(--brand); color: #fff; border-color: var(--brand); }
.ltr-controls { .ltr-controls {
display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center; display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center;
direction: ltr; direction: ltr;
@@ -396,19 +403,16 @@ button, input { font: inherit; }
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
direction: ltr; /* spacer left, menu right */
} }
.sheet-header-spacer { width: 44px; height: 44px; }
.sheet-header-title { .sheet-header-title {
direction: rtl;
text-align: center; text-align: center;
font-weight: 700; font-weight: 700;
font-size: 1rem; font-size: 1rem;
color: var(--ink); 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 { .player-hero {
width: min(100%, 360px); width: min(100%, 360px);
aspect-ratio: 1; aspect-ratio: 1;
@@ -453,14 +457,7 @@ button, input { font: inherit; }
color: var(--ink); font-size: 0.85rem; padding: 0.35rem; color: var(--ink); font-size: 0.85rem; padding: 0.35rem;
} }
.sheet-action.on { color: var(--brand); font-weight: 700; } .sheet-action.on { color: var(--brand); font-weight: 700; }
.sheet-action-icon { .sheet-action-icon { font-size: 1.25rem; }
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-block { margin-top: auto; padding-top: 0.5rem; }
.sheet-seek { width: 100%; accent-color: var(--brand); } .sheet-seek { width: 100%; accent-color: var(--brand); }
.sheet-times { .sheet-times {
@@ -485,7 +482,8 @@ button, input { font: inherit; }
} }
.skip-btn { .skip-btn {
width: 52px; height: 52px; border-radius: 50%; width: 52px; height: 52px; border-radius: 50%;
border: 1px solid rgba(28, 43, 36, 0.22); background: #fff; border: 1px solid rgba(28, 43, 36, 0.14); background: #fff;
box-shadow: 0 2px 8px rgba(28, 43, 36, 0.08);
cursor: pointer; font-weight: 700; color: var(--brand-deep); cursor: pointer; font-weight: 700; color: var(--brand-deep);
} }
@@ -498,12 +496,18 @@ button, input { font: inherit; }
overflow: hidden; overflow: hidden;
} }
.toc-header { .toc-header {
display: flex; align-items: center; gap: 0.65rem; display: grid;
grid-template-columns: 44px 1fr;
align-items: center;
gap: 0.65rem;
padding: 0 1rem 0.85rem; padding: 0 1rem 0.85rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
direction: ltr; /* back button physical left */
} }
.toc-header-text { .toc-header-text {
display: grid; gap: 0.15rem; display: grid; gap: 0.15rem;
direction: rtl;
text-align: right;
} }
.toc-header-text strong { font-size: 1.15rem; color: var(--ink); } .toc-header-text strong { font-size: 1.15rem; color: var(--ink); }
.toc-skeleton { padding: 1rem; } .toc-skeleton { padding: 1rem; }
+102
View File
@@ -80,3 +80,105 @@ window.tqAudio = {
}; };
} }
}; };
window.tqSheet = {
_handlers: new WeakMap(),
_ignored: (target) => {
if (!target || !target.closest) return false;
return !!target.closest('.seek, input, .player-main-controls, .sheet-actions, .sheet-icon-btn, button, a');
},
bindDismiss: (el, dotnet, methodName) => {
if (!el || !dotnet) return;
window.tqSheet.unbindDismiss(el);
const state = { startY: 0, startX: 0, active: false, dragging: false };
const threshold = 80;
const method = methodName || 'OnSwipeDismiss';
const resetTransform = () => {
el.style.transition = 'transform 0.22s ease, opacity 0.22s ease';
el.style.transform = '';
el.style.opacity = '';
window.setTimeout(() => { el.style.transition = ''; }, 240);
};
const onStart = (e) => {
if (window.tqSheet._ignored(e.target)) return;
const point = e.touches ? e.touches[0] : e;
if (!point) return;
state.startY = point.clientY;
state.startX = point.clientX;
state.active = true;
state.dragging = false;
el.style.transition = 'none';
};
const onMove = (e) => {
if (!state.active) return;
const point = e.touches ? e.touches[0] : e;
if (!point) return;
const dy = point.clientY - state.startY;
const dx = point.clientX - state.startX;
if (!state.dragging) {
if (Math.abs(dy) < 8 && Math.abs(dx) < 8) return;
if (Math.abs(dx) > Math.abs(dy) || dy < 0) {
state.active = false;
return;
}
state.dragging = true;
}
if (e.cancelable) e.preventDefault();
const y = Math.max(0, dy);
el.style.transform = `translateY(${y}px)`;
el.style.opacity = String(Math.max(0.45, 1 - y / 420));
};
const onEnd = (e) => {
if (!state.active && !state.dragging) return;
const point = (e.changedTouches && e.changedTouches[0]) || e;
const dy = point ? (point.clientY - state.startY) : 0;
const dx = point ? (point.clientX - state.startX) : 0;
const shouldDismiss = state.dragging && dy >= threshold && Math.abs(dx) < dy;
state.active = false;
state.dragging = false;
if (shouldDismiss) {
el.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
el.style.transform = 'translateY(100%)';
el.style.opacity = '0';
dotnet.invokeMethodAsync(method);
} else {
resetTransform();
}
};
el.addEventListener('touchstart', onStart, { passive: true });
el.addEventListener('touchmove', onMove, { passive: false });
el.addEventListener('touchend', onEnd, { passive: true });
el.addEventListener('touchcancel', onEnd, { passive: true });
el.addEventListener('pointerdown', onStart);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onEnd);
el.addEventListener('pointercancel', onEnd);
window.tqSheet._handlers.set(el, { onStart, onMove, onEnd });
},
unbindDismiss: (el) => {
const h = window.tqSheet._handlers.get(el);
if (!h || !el) return;
el.removeEventListener('touchstart', h.onStart);
el.removeEventListener('touchmove', h.onMove);
el.removeEventListener('touchend', h.onEnd);
el.removeEventListener('touchcancel', h.onEnd);
el.removeEventListener('pointerdown', h.onStart);
el.removeEventListener('pointermove', h.onMove);
el.removeEventListener('pointerup', h.onEnd);
el.removeEventListener('pointercancel', h.onEnd);
el.style.transform = '';
el.style.opacity = '';
el.style.transition = '';
window.tqSheet._handlers.delete(el);
}
};