From a5bf2ac245982da48e3b83bffb2cf13e9c6a9f1f Mon Sep 17 00:00:00 2001 From: w33b Date: Sun, 19 Jul 2026 20:13:55 +0200 Subject: [PATCH] Add video engagement tracking and heatmap --- .../Controllers/Api/StreamApiController.php | 70 +++++ app/Models/VideoEngagement.php | 34 +++ ...9_141500_create_video_engagement_table.php | 33 +++ resources/css/app.css | 18 ++ resources/js/player-engagement.js | 72 ++++++ resources/js/player-heatmap.js | 241 ++++++++++++++++++ resources/js/player.js | 20 ++ .../views/stream/partials/info.blade.php | 1 + routes/web.php | 2 + 9 files changed, 491 insertions(+) create mode 100644 app/Models/VideoEngagement.php create mode 100644 database/migrations/2026_07_19_141500_create_video_engagement_table.php create mode 100644 resources/js/player-engagement.js create mode 100644 resources/js/player-heatmap.js diff --git a/app/Http/Controllers/Api/StreamApiController.php b/app/Http/Controllers/Api/StreamApiController.php index 230f248..f396c49 100644 --- a/app/Http/Controllers/Api/StreamApiController.php +++ b/app/Http/Controllers/Api/StreamApiController.php @@ -4,11 +4,14 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Episode; +use App\Models\VideoEngagement; use App\Models\Watched; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; class StreamApiController extends Controller { @@ -70,4 +73,71 @@ class StreamApiController extends Controller return response()->json(['watched' => true], 200); } + + /** + * Track engagement segments that the authenticated user has watched. + * Called periodically client-side while the video is playing. + * Segments are 10-second chunks; segment 0 (0-10s) is excluded. + */ + public function trackEngagement(Request $request): JsonResponse + { + $validated = $request->validate([ + 'episode_id' => 'required|integer|exists:episodes,id', + 'segments' => 'required|array', + 'segments.*' => 'integer|min:1', + ]); + + $user = Auth::user(); + if (! $user) { + return response()->json(['tracked' => 0], 401); + } + + $rateLimitKey = 'engagement:'.$user->id; + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $seconds = RateLimiter::availableIn($rateLimitKey); + + return response()->json([ + 'tracked' => 0, + 'message' => 'Rate limit exceeded. Try again in '.$seconds.' seconds.', + ], 429); + } + RateLimiter::hit($rateLimitKey, 60); + + $episodeId = $request->input('episode_id'); + $segments = $request->input('segments'); + $tracked = 0; + + foreach ($segments as $segment) { + // upsert: insert if not exists, otherwise ignore (unique constraint prevents dupes) + VideoEngagement::firstOrCreate([ + 'episode_id' => $episodeId, + 'user_id' => $user->id, + 'segment' => $segment, + ]); + $tracked++; + } + + return response()->json(['tracked' => $tracked], 200); + } + + /** + * Get engagement heatmap data for an episode. + * Returns watch counts per segment, aggregated across all users. + */ + public function getEngagement(int $episodeId): JsonResponse + { + $episode = Episode::findOrFail($episodeId); + + $data = cache()->remember( + "engagement:{$episodeId}", + 600, // 10-minute cache + fn () => VideoEngagement::where('episode_id', $episodeId) + ->select('segment', DB::raw('count(*) as watch_count')) + ->groupBy('segment') + ->pluck('watch_count', 'segment') + ->toArray() + ); + + return response()->json($data, 200); + } } diff --git a/app/Models/VideoEngagement.php b/app/Models/VideoEngagement.php new file mode 100644 index 0000000..6f51fd3 --- /dev/null +++ b/app/Models/VideoEngagement.php @@ -0,0 +1,34 @@ +belongsTo(Episode::class, 'episode_id'); + } + + /** + * Get the User. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} \ No newline at end of file diff --git a/database/migrations/2026_07_19_141500_create_video_engagement_table.php b/database/migrations/2026_07_19_141500_create_video_engagement_table.php new file mode 100644 index 0000000..5ce6515 --- /dev/null +++ b/database/migrations/2026_07_19_141500_create_video_engagement_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('episode_id')->constrained('episodes')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedSmallInteger('segment'); + $table->timestamps(); + + // One row per user per episode per segment — no duplicates + $table->unique(['episode_id', 'user_id', 'segment']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('video_engagement'); + } +}; \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 90678d5..abfaf3f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -31,6 +31,24 @@ border-radius: 15px; } +/* Player Engagement Heatmap, overlays the progress bar track */ +.plyr__progress__heatmap { + position: absolute; + left: -6px; + right: -6px; + bottom: 40%; + height: 48px; + pointer-events: none; + z-index: 0; +} + +.plyr__progress__heatmap-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + /* Player Ambient */ .decoy { position: absolute; diff --git a/resources/js/player-engagement.js b/resources/js/player-engagement.js new file mode 100644 index 0000000..7280c7d --- /dev/null +++ b/resources/js/player-engagement.js @@ -0,0 +1,72 @@ +// Engagement heatmap tracking +// Samples the user's current time while playing and sends batched segment data to the server. +// Only tracks segment >= 1 (excludes 0-10s). +// Only calls the endpoint when the user is logged in. + +let engagementInterval; +let engagementSegments = new Set(); +let engagementReportInterval; +const SEGMENT_DURATION = 10; // seconds per segment +const SAMPLE_INTERVAL = 5000; // sample every 5s +const REPORT_INTERVAL = 15000; // send batch every 15s + +function isAuthenticated() { + const el = document.getElementById('auth_check'); + return el && el.value === '1'; +} + +function sendEngagement(episodeId, segments) { + if (!isAuthenticated()) return; + + window.axios.post('/player/engagement', { + episode_id: episodeId, + segments: segments, + }).catch(() => { + // Fire-and-forget: silently ignore network errors + }); +} + +export function startEngagementTracking(episodeId) { + engagementSegments.clear(); + + // Sample current time while playing + engagementInterval = setInterval(() => { + const video = document.querySelector('video'); + if (!video || video.paused) return; + + const segment = Math.floor(video.currentTime / SEGMENT_DURATION); + // Skip segment 0 (0-10s) — no need to track the very start + if (segment >= 1) { + engagementSegments.add(segment); + } + }, SAMPLE_INTERVAL); + + // Batch report to server + engagementReportInterval = setInterval(() => { + if (engagementSegments.size === 0) return; + + const segments = Array.from(engagementSegments); + engagementSegments.clear(); + + sendEngagement(episodeId, segments); + }, REPORT_INTERVAL); + + // Flush remaining segments & cleanup on page unload + const cleanup = () => { + clearInterval(engagementInterval); + clearInterval(engagementReportInterval); + + if (engagementSegments.size > 0) { + const segments = Array.from(engagementSegments); + engagementSegments.clear(); + sendEngagement(episodeId, segments); + } + }; + + window.addEventListener('beforeunload', cleanup); +} + +export function stopEngagementTracking() { + if (engagementInterval) clearInterval(engagementInterval); + if (engagementReportInterval) clearInterval(engagementReportInterval); +} \ No newline at end of file diff --git a/resources/js/player-heatmap.js b/resources/js/player-heatmap.js new file mode 100644 index 0000000..02fff22 --- /dev/null +++ b/resources/js/player-heatmap.js @@ -0,0 +1,241 @@ +// Engagement heatmap display +// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track. + +let heatmapContainer = null; +let heatmapCanvas = null; +let heatmapResizeObserver = null; + +/** + * Fetch engagement data from the server and render the heatmap. + * @param {string} episodeId - The episode ID. + * @param {number} duration - Video duration in seconds. + */ +export async function renderHeatmap(episodeId, duration) { + try { + const response = await window.axios.get(`/player/engagement/${episodeId}`); + const data = response.data; + + if (!data || Object.keys(data).length === 0) { + return; + } + + drawHeatmapCurve(data, duration); + } catch (error) { + console.error('Failed to load engagement data:', error); + } +} + +/** + * Draw a smooth area chart on a canvas element above the progress bar. + * @param {Object} data - Key-value map of segment -> watch_count. + * @param {number} duration - Video duration in seconds. + */ +function drawHeatmapCurve(data, duration) { + const SEGMENT_DURATION = 10; + const totalSegments = Math.ceil(duration / SEGMENT_DURATION); + + // Build raw counts array, filling gaps with 0 + const rawCounts = []; + for (let i = 0; i < totalSegments; i++) { + rawCounts.push(data[i] || 0); + } + + // Apply 3-point moving average to smooth individual spikes + const counts = smoothData(rawCounts); + + const maxCount = Math.max(...counts, 1); + + // Remove existing heatmap if present + if (heatmapContainer) { + if (heatmapResizeObserver) heatmapResizeObserver.disconnect(); + heatmapContainer.remove(); + heatmapCanvas = null; + } + + // Find the progress bar wrapper + const progressBar = document.querySelector('.plyr__progress'); + if (!progressBar) return; + + // Create container + heatmapContainer = document.createElement('div'); + heatmapContainer.className = 'plyr__progress__heatmap'; + heatmapContainer.setAttribute('aria-hidden', 'true'); + + // Create canvas + heatmapCanvas = document.createElement('canvas'); + heatmapCanvas.className = 'plyr__progress__heatmap-canvas'; + heatmapContainer.appendChild(heatmapCanvas); + + // Insert as first child of the progress bar so it sits behind the scrubber + progressBar.insertBefore(heatmapContainer, progressBar.firstChild); + + // Defer drawing to get container dimensions + requestAnimationFrame(() => { + requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount)); + }); + + // Redraw on resize + heatmapResizeObserver = new ResizeObserver(() => { + drawCurve(heatmapCanvas, counts, maxCount); + }); + heatmapResizeObserver.observe(heatmapContainer); +} + +/** + * Apply a 3-point moving average to smooth out jaggedness. + * Preserves the first and last points. + */ +function smoothData(data) { + if (data.length <= 2) return [...data]; + + const smoothed = [data[0]]; // preserve first + + for (let i = 1; i < data.length - 1; i++) { + smoothed.push((data[i - 1] + data[i] + data[i + 1]) / 3); + } + + smoothed.push(data[data.length - 1]); // preserve last + return smoothed; +} + +/** + * Render vertical bar heatmap directly on the progress bar track. + * Each bar represents a time segment; taller bars = more engagement. + */ +function drawCurve(canvas, counts, maxCount) { + const parent = canvas.parentElement; + if (!parent) return; + + const rect = parent.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + const dpr = window.devicePixelRatio || 1; + const w = rect.width; + const h = rect.height; + + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + canvas.style.width = w + 'px'; + canvas.style.height = h + 'px'; + + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + if (counts.length === 0 || maxCount === 0) return; + + // Padding: leave 1px on each side so the curve doesn't clip at edges + const paddingX = 1; + const paddingY = 1; + const drawW = w - paddingX * 2; + const drawH = h - paddingY * 2; + const baseline = paddingY + drawH / 2; // curve oscillates around the center + const amplitude = (drawH / 2) * 0.8; // 80% of half-height to keep inside bounds + const n = counts.length; + + // Build data points: x = horizontal position, y = vertical offset from center + const pts = []; + for (let i = 0; i < n; i++) { + const x = paddingX + (i / (n - 1 || 1)) * drawW; + const ratio = counts[i] / maxCount; + // ratio 0 = bottom of amplitude range, ratio 1 = top of amplitude range + const y = baseline - (ratio - 0.5) * amplitude * 2; + pts.push({ x, y }); + } + + if (pts.length < 2) return; + + // Build the smooth path using quadratic bezier curves through midpoints + const path = [{ x: pts[0].x, y: pts[0].y }]; + + for (let i = 0; i < pts.length - 1; i++) { + const midX = (pts[i].x + pts[i + 1].x) / 2; + const midY = (pts[i].y + pts[i + 1].y) / 2; + path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } }); + } + path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }); + + // --- Draw a subtle glow behind the line --- + ctx.beginPath(); + ctx.moveTo(path[0].x, path[0].y); + for (let i = 1; i < path.length; i++) { + const prev = path[i - 1]; + const curr = path[i]; + if (curr.cp) { + ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y); + } else { + ctx.lineTo(curr.x, curr.y); + } + } + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 3.0; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // --- Draw the main waveform line --- + ctx.beginPath(); + ctx.moveTo(path[0].x, path[0].y); + for (let i = 1; i < path.length; i++) { + const prev = path[i - 1]; + const curr = path[i]; + if (curr.cp) { + ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y); + } else { + ctx.lineTo(curr.x, curr.y); + } + } + ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)'; + ctx.lineWidth = 1.5; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); +} + +/** + * Return a heatmap-style color for a given ratio (0..1). + * Low = cool blue/gray, mid = warm amber, high = hot red/pink. + */ +function heatmapColor(ratio) { + // Clamp + const t = Math.max(0, Math.min(1, ratio)); + + // Four-stop gradient: cool → warm → hot + if (t < 0.33) { + // Cool muted purple-blue → warm amber + const s = t / 0.33; + const r = Math.round(99 + s * (245 - 99)); + const g = Math.round(114 + s * (158 - 114)); + const b = Math.round(176 + s * (48 - 176)); + return `rgba(${r},${g},${b},0.70)`; + } else if (t < 0.66) { + // Warm amber → hot orange + const s = (t - 0.33) / 0.33; + const r = Math.round(245 + s * (255 - 245)); + const g = Math.round(158 + s * (107 - 158)); + const b = Math.round(48 + s * (35 - 48)); + return `rgba(${r},${g},${b},0.75)`; + } else { + // Hot orange → intense red/pink + const s = (t - 0.66) / 0.34; + const r = Math.round(255 + s * (255 - 255)); + const g = Math.round(107 + s * (75 - 107)); + const b = Math.round(35 + s * (85 - 35)); + return `rgba(${r},${g},${b},0.80)`; + } +} + +/** + * Remove the heatmap from the DOM. + */ +export function removeHeatmap() { + if (heatmapResizeObserver) { + heatmapResizeObserver.disconnect(); + heatmapResizeObserver = null; + } + if (heatmapContainer) { + heatmapContainer.remove(); + heatmapContainer = null; + heatmapCanvas = null; + } +} \ No newline at end of file diff --git a/resources/js/player.js b/resources/js/player.js index 8686dca..9a6e10d 100644 --- a/resources/js/player.js +++ b/resources/js/player.js @@ -21,6 +21,8 @@ import { addVideoTracks } from './player-data'; import { addSubtitleTracks } from './player-data'; import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select'; import { isIOS } from './detect-ios'; +import { startEngagementTracking, stopEngagementTracking } from './player-engagement'; +import { renderHeatmap } from './player-heatmap'; // Variables var player = null; @@ -166,6 +168,7 @@ function toggleAsiaServer() { if (player) { clearInterval(saveInterval); + stopEngagementTracking(); player.destroy(); } initPlayer(); @@ -291,6 +294,10 @@ function initPlayer() { console.log("Stopped video, because user didn't click play.") } + // Start engagement heatmap tracking + const episodeId = document.getElementById('e_id').value; + startEngagementTracking(episodeId); + setCanvasDimension(canvas, video); console.log('Play => Function Loop()'); var $this = video; @@ -380,6 +387,18 @@ function initPlayer() { player.on('ready', () => { mobileDoubleClick(player); + + // Load engagement heatmap once video duration is known + const video = document.querySelector('video'); + const episodeId = document.getElementById('e_id').value; + if (video && video.duration) { + renderHeatmap(episodeId, video.duration); + } else if (video) { + video.addEventListener('loadedmetadata', function onMeta() { + video.removeEventListener('loadedmetadata', onMeta); + renderHeatmap(episodeId, video.duration); + }); + } }); // Server Select @@ -405,6 +424,7 @@ function initPlayer() { if (player) { clearInterval(saveInterval); + stopEngagementTracking(); player.destroy(); } initPlayer(); diff --git a/resources/views/stream/partials/info.blade.php b/resources/views/stream/partials/info.blade.php index 4821b1d..2d97e7f 100644 --- a/resources/views/stream/partials/info.blade.php +++ b/resources/views/stream/partials/info.blade.php @@ -8,6 +8,7 @@
@if($streamPage) + @endif
diff --git a/routes/web.php b/routes/web.php index 5c924f9..55b478c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -28,6 +28,8 @@ Route::get('/v1/monthly-views', [HentaiApiController::class, 'getMonthlyViews']) Route::get('/hentai/{title}', [StreamController::class, 'index'])->name('hentai.index'); Route::post('/player/api', [StreamApiController::class, 'getStream'])->name('hentai.player'); Route::post('/watched/track', [StreamApiController::class, 'trackWatched'])->name('hentai.watched'); +Route::post('/player/engagement', [StreamApiController::class, 'trackEngagement'])->name('hentai.engagement'); +Route::get('/player/engagement/{episodeId}', [StreamApiController::class, 'getEngagement'])->name('hentai.engagement.data'); // Search Route::get('/search', [HomeController::class, 'search'])->name('hentai.search');