feat(shorts): /shorts/{id} deep-link + URL sync + persistent mute + wheel nav

- New route /shorts/{video} opens the feed scrolled to that short.
  Controller aborts 404 when the id doesn't resolve to a short or
  isn't viewable by the current viewer, so bad links land on the
  branded 404 (added in the next commit) rather than loading an
  empty feed.
- The IntersectionObserver that detects the active short now
  history.replaceState()s the URL to /shorts/{id} so copy-paste of
  the address bar works, without pushing a navigation entry — the
  back button still exits the feed in one step.
- Mute state is persisted in localStorage (shortsMuted = '0' | '1')
  so unmuting once sticks across sessions. Default remains muted
  to satisfy autoplay policies on first load.
- Mouse-wheel navigation moves one short per gesture with a 450 ms
  debounce. Trackpad flicks no longer skip five shorts at once.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:30:42 +03:00
1 parent 646d3e9d2a
commit 9e613c05d2
3 files changed
+67 -6

No files matched your search

+44 -3
View File
@@ -432,9 +432,22 @@
<script>
(function () {
const main = document.getElementById('main');
let muted = false;
// Persist the mute state across sessions. Default to muted so autoplay
// policies don't silently fail on first load; the user can unmute once
// and we remember it from then on.
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
let current = -1;
// ── Deep-link to /shorts/{id} — scroll the matching item into view
// before the observer starts, so autoplay fires on the target short
// rather than the first in document order. ────────────────────
@if($startId)
(function () {
const target = document.querySelector('.short-item[data-id="{{ (int) $startId }}"]');
if (target) target.scrollIntoView({ behavior: 'instant' in window ? 'instant' : 'auto', block: 'start' });
})();
@endif
// ── IntersectionObserver — auto-play visible short ─────────
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
@@ -446,12 +459,23 @@
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
if (current !== -1 && current !== idx) pauseShort(current);
current = idx;
video.muted = muted;
// ── Reflect the active short in the URL (no history entry) ──
// Users can copy-paste the address bar and get back here; the
// back button still exits the feed instead of walking through
// every short they viewed.
try {
const vid = item.dataset.id;
if (vid) history.replaceState(null, '', '/shorts/' + vid);
} catch (_) {}
video.muted = muted;
video.volume = 1;
video.play().catch(() => {
// autoplay blocked — start muted then unmute
// autoplay blocked — fall back to muted
video.muted = true;
muted = true;
localStorage.setItem('shortsMuted', '1');
updateMuteIcon(idx);
video.play().catch(() => {});
});
@@ -500,6 +524,7 @@
// ── Mute toggle ────────────────────────────────────────────
window.toggleMute = function(idx) {
muted = !muted;
localStorage.setItem('shortsMuted', muted ? '1' : '0');
const video = document.getElementById('sv-' + idx);
if (video) video.muted = muted;
updateMuteIcon(idx);
@@ -528,6 +553,22 @@
}
});
// ── Mouse wheel nav (desktop) ──────────────────────────────
// One wheel event = one short, debounced so a trackpad flick doesn't
// skip five shorts at once. The main scroll container's own snap would
// nearly handle this, but a slow scroll leaves the feed mid-snap on
// some browsers — this makes the step explicit and symmetrical.
let wheelLock = false;
main?.addEventListener('wheel', (e) => {
if (wheelLock) { e.preventDefault(); return; }
if (Math.abs(e.deltaY) < 10) return; // ignore tiny jogs
wheelLock = true;
setTimeout(() => { wheelLock = false; }, 450);
e.preventDefault();
const dir = e.deltaY > 0 ? 1 : -1;
if (current !== -1) navigateShort(dir, current);
}, { passive: false });
// ── Like ───────────────────────────────────────────────────
window.shortLike = function(videoId) {
@auth