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:
1 parent
646d3e9d2a
commit
9e613c05d2
3 files changed
+67
-6
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user