Add sports-match type, device tracking, profile visits, and share refactor

- New SportsMatch model/controller and sports UI components/modal
- Move share-modal to a reusable x-share-modal/x-share-button component
- Add VideoSharedWithUser notification and share-to-members flow
- Device/user-agent tracking on views, downloads, share accesses
- ProfileVisit model + migration; subscription source tracking
- Email thumbnail support; remove stale TODO files
This commit is contained in:
ghassan committed 2026-05-29 01:50:28 +03:00
1 parent 6aae6f86b6
commit 73527f3781
61 files changed
+4014 -636

No files matched your search

@@ -7,12 +7,8 @@
])
@php
$orientationClass = match($video->orientation ?? 'landscape') {
'portrait' => 'portrait',
'square' => 'square',
'ultrawide' => 'ultrawide',
default => '',
};
// Force 16:9 for every video — orientation classes intentionally disabled
$orientationClass = '';
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
@@ -35,7 +31,9 @@
{{-- ══════════════════════════════════════════════
PLAYER WRAP — theater class toggled by JS
══════════════════════════════════════════════ --}}
<div class="ytp-wrap {{ $orientationClass }}" id="ytpWrap">
<div class="ytp-wrap {{ $orientationClass }}" id="ytpWrap"
data-progress-url="{{ route('videos.viewProgress', $video) }}"
data-video-id="{{ $video->id }}">
<div class="ytp" id="videoContainer" tabindex="0">
{{-- ── Video element ── --}}
@@ -1399,6 +1397,73 @@ function init() {
document.addEventListener('DOMContentLoaded', init);
// ── View-progress heartbeat ──────────────────────────────
(function() {
const _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
let _hbVideoId = null;
let _hbUrl = null;
let _hbLast = 0;
let _hbCompleted = false;
function _refreshHbTarget() {
const wrap = document.getElementById('ytpWrap');
if (!wrap) return;
const vid = parseInt(wrap.dataset.videoId || '0', 10);
if (vid && vid !== _hbVideoId) {
_hbVideoId = vid;
_hbUrl = wrap.dataset.progressUrl || null;
_hbLast = 0;
_hbCompleted = false;
}
}
function _sendHb(completed) {
if (!_hbUrl) return;
const v = document.getElementById('videoPlayer');
if (!v) return;
const cur = Math.floor(v.currentTime || 0);
if (!completed && cur <= _hbLast) return;
const body = new URLSearchParams({ watched_seconds: cur, completed: completed ? '1' : '0' });
try {
if (completed && navigator.sendBeacon) {
const blob = new Blob([body.toString() + '&_token=' + _csrf], { type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(_hbUrl, blob);
} else {
fetch(_hbUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': _csrf, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: body.toString(),
keepalive: true,
credentials: 'same-origin',
}).catch(function() {});
}
_hbLast = cur;
if (completed) _hbCompleted = true;
} catch (e) {}
}
function _hbBind() {
const v = document.getElementById('videoPlayer');
if (!v || v._hbBound) return;
v._hbBound = true;
_refreshHbTarget();
v.addEventListener('ended', () => _sendHb(true));
v.addEventListener('pause', () => _sendHb(false));
}
setInterval(function() { _refreshHbTarget(); if (!_hbCompleted) _sendHb(false); }, 5000);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') _sendHb(false);
});
window.addEventListener('pagehide', function() { _sendHb(false); });
document.addEventListener('DOMContentLoaded', _hbBind);
// also rebind after SPA source swaps
const _origLoad = window._ytpLoadSource;
if (typeof _origLoad === 'function') {
window._ytpLoadSource = function() {
_refreshHbTarget();
return _origLoad.apply(this, arguments);
};
}
})();
})();
</script>
@endonce