diff --git a/src/TafsirQuran.UI/Components/AudioPlayer.razor b/src/TafsirQuran.UI/Components/AudioPlayer.razor index a0f0866..c38efd3 100644 --- a/src/TafsirQuran.UI/Components/AudioPlayer.razor +++ b/src/TafsirQuran.UI/Components/AudioPlayer.razor @@ -41,8 +41,8 @@ } else { -
- +
+
@HeaderTitle
@@ -147,6 +147,8 @@ private double duration; private string boundKey = ""; private bool bound; + private bool sheetBound; + private PlayerLayout lastSheetLayout = PlayerLayout.Dock; private double lastReported = -1; private bool HasAudio => !string.IsNullOrWhiteSpace(AudioUrl); @@ -156,11 +158,27 @@ 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 (!bound) { - self = DotNetObjectReference.Create(this); await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self); bound = true; } @@ -188,10 +206,12 @@ 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(); + [JSInvokable] + public Task OnSwipeDismiss() => OnCollapse.InvokeAsync(); + private async Task TogglePlay() { playing = await Js.InvokeAsync("tqAudio.toggle", audioEl); @@ -260,6 +280,8 @@ { try { + if (sheetBound) + await Js.InvokeVoidAsync("tqSheet.unbindDismiss", root); if (bound) await Js.InvokeVoidAsync("tqAudio.unbind", audioEl); } diff --git a/src/TafsirQuran.UI/Components/PlayerToc.razor b/src/TafsirQuran.UI/Components/PlayerToc.razor index e85c67b..e9abff7 100644 --- a/src/TafsirQuran.UI/Components/PlayerToc.razor +++ b/src/TafsirQuran.UI/Components/PlayerToc.razor @@ -5,7 +5,7 @@ {
- +
سوره @Session.SurahNameFa @Session.TafsirTitle diff --git a/src/TafsirQuran.UI/wwwroot/css/app.css b/src/TafsirQuran.UI/wwwroot/css/app.css index db0909a..7ebeff2 100644 --- a/src/TafsirQuran.UI/wwwroot/css/app.css +++ b/src/TafsirQuran.UI/wwwroot/css/app.css @@ -290,11 +290,25 @@ button, input { font: inherit; } 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: 52px; height: 52px; border-radius: 14px; - border: 1px solid var(--line); background: #fff; cursor: pointer; - font-size: 1.6rem; line-height: 1; color: var(--brand-deep); +.back-btn, +.sheet-icon-btn, +.icon-chip, +.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; + 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 { 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; } .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 { display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center; direction: ltr; @@ -396,19 +403,16 @@ button, input { font: inherit; } align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; + direction: ltr; /* spacer left, menu right */ } +.sheet-header-spacer { width: 44px; height: 44px; } .sheet-header-title { + direction: rtl; 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; @@ -453,14 +457,7 @@ button, input { font: inherit; } 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-action-icon { font-size: 1.25rem; } .sheet-seek-block { margin-top: auto; padding-top: 0.5rem; } .sheet-seek { width: 100%; accent-color: var(--brand); } .sheet-times { @@ -485,7 +482,8 @@ button, input { font: inherit; } } .skip-btn { 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); } @@ -498,12 +496,18 @@ button, input { font: inherit; } overflow: hidden; } .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; border-bottom: 1px solid var(--line); + direction: ltr; /* back button physical left */ } .toc-header-text { display: grid; gap: 0.15rem; + direction: rtl; + text-align: right; } .toc-header-text strong { font-size: 1.15rem; color: var(--ink); } .toc-skeleton { padding: 1rem; } diff --git a/src/TafsirQuran.UI/wwwroot/js/app.js b/src/TafsirQuran.UI/wwwroot/js/app.js index 0fd30fd..b3e570c 100644 --- a/src/TafsirQuran.UI/wwwroot/js/app.js +++ b/src/TafsirQuran.UI/wwwroot/js/app.js @@ -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); + } +};