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

+19 -3
View File
@@ -26,7 +26,7 @@ class VideoController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']); $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'shortsFocused', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']);
} }
/** /**
@@ -2190,7 +2190,7 @@ class VideoController extends Controller
]); ]);
} }
public function shorts(Request $request) public function shorts(Request $request, ?int $startId = null)
{ {
$videos = Video::public() $videos = Video::public()
->where('is_shorts', true) ->where('is_shorts', true)
@@ -2199,7 +2199,23 @@ class VideoController extends Controller
->latest() ->latest()
->get(); ->get();
return view('videos.shorts', compact('videos')); return view('videos.shorts', [
'videos' => $videos,
'startId' => $startId, // the id to auto-scroll to, if any
]);
}
/**
* /shorts/{video}: deep-link to a specific short in the vertical feed.
* The feed itself is the same ordered list; the view uses ?start= / the
* $startId prop to scroll into position on load.
*/
public function shortsFocused(Video $video)
{
if (! $video->is_shorts || ! $video->canView(Auth::user())) {
abort(404);
}
return $this->shorts(request(), $video->id);
} }
public function stream(Video $video) public function stream(Video $video)
+44 -3
View File
@@ -432,9 +432,22 @@
<script> <script>
(function () { (function () {
const main = document.getElementById('main'); 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; 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 ───────── // ── IntersectionObserver — auto-play visible short ─────────
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
entries.forEach(entry => { entries.forEach(entry => {
@@ -446,12 +459,23 @@
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) { if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
if (current !== -1 && current !== idx) pauseShort(current); if (current !== -1 && current !== idx) pauseShort(current);
current = idx; 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.volume = 1;
video.play().catch(() => { video.play().catch(() => {
// autoplay blocked — start muted then unmute // autoplay blocked — fall back to muted
video.muted = true; video.muted = true;
muted = true; muted = true;
localStorage.setItem('shortsMuted', '1');
updateMuteIcon(idx); updateMuteIcon(idx);
video.play().catch(() => {}); video.play().catch(() => {});
}); });
@@ -500,6 +524,7 @@
// ── Mute toggle ──────────────────────────────────────────── // ── Mute toggle ────────────────────────────────────────────
window.toggleMute = function(idx) { window.toggleMute = function(idx) {
muted = !muted; muted = !muted;
localStorage.setItem('shortsMuted', muted ? '1' : '0');
const video = document.getElementById('sv-' + idx); const video = document.getElementById('sv-' + idx);
if (video) video.muted = muted; if (video) video.muted = muted;
updateMuteIcon(idx); 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 ─────────────────────────────────────────────────── // ── Like ───────────────────────────────────────────────────
window.shortLike = function(videoId) { window.shortLike = function(videoId) {
@auth @auth
+4
View File
@@ -34,6 +34,10 @@ Route::get('/videos/search', function (\Illuminate\Http\Request $request) {
})->name('videos.search'); })->name('videos.search');
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending'); Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts'); Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
// Deep-link form: /shorts/{encoded-id} loads the feed scrolled to that short.
// The resolveRouteBinding on Video decodes the id; if it's gone, 404 lands
// on the branded errors/404 page.
Route::get('/shorts/{video}', [VideoController::class, 'shortsFocused'])->name('videos.shorts.focused');
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create'); Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility) // Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
Route::get('/videos/{numericId}', function ($numericId) { Route::get('/videos/{numericId}', function ($numericId) {