Add video engagement tracking and heatmap
This commit is contained in:
@@ -4,11 +4,14 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
|
use App\Models\VideoEngagement;
|
||||||
use App\Models\Watched;
|
use App\Models\Watched;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
||||||
class StreamApiController extends Controller
|
class StreamApiController extends Controller
|
||||||
{
|
{
|
||||||
@@ -70,4 +73,71 @@ class StreamApiController extends Controller
|
|||||||
|
|
||||||
return response()->json(['watched' => true], 200);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VideoEngagement extends Model
|
||||||
|
{
|
||||||
|
public $table = 'video_engagement';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
protected $fillable = ['episode_id', 'user_id', 'segment'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Episode.
|
||||||
|
*/
|
||||||
|
public function episode(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Episode::class, 'episode_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the User.
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('video_engagement', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -31,6 +31,24 @@
|
|||||||
border-radius: 15px;
|
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 */
|
/* Player Ambient */
|
||||||
.decoy {
|
.decoy {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import { addVideoTracks } from './player-data';
|
|||||||
import { addSubtitleTracks } from './player-data';
|
import { addSubtitleTracks } from './player-data';
|
||||||
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
||||||
import { isIOS } from './detect-ios';
|
import { isIOS } from './detect-ios';
|
||||||
|
import { startEngagementTracking, stopEngagementTracking } from './player-engagement';
|
||||||
|
import { renderHeatmap } from './player-heatmap';
|
||||||
|
|
||||||
// Variables
|
// Variables
|
||||||
var player = null;
|
var player = null;
|
||||||
@@ -166,6 +168,7 @@ function toggleAsiaServer() {
|
|||||||
|
|
||||||
if (player) {
|
if (player) {
|
||||||
clearInterval(saveInterval);
|
clearInterval(saveInterval);
|
||||||
|
stopEngagementTracking();
|
||||||
player.destroy();
|
player.destroy();
|
||||||
}
|
}
|
||||||
initPlayer();
|
initPlayer();
|
||||||
@@ -291,6 +294,10 @@ function initPlayer() {
|
|||||||
console.log("Stopped video, because user didn't click play.")
|
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);
|
setCanvasDimension(canvas, video);
|
||||||
console.log('Play => Function Loop()');
|
console.log('Play => Function Loop()');
|
||||||
var $this = video;
|
var $this = video;
|
||||||
@@ -380,6 +387,18 @@ function initPlayer() {
|
|||||||
|
|
||||||
player.on('ready', () => {
|
player.on('ready', () => {
|
||||||
mobileDoubleClick(player);
|
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
|
// Server Select
|
||||||
@@ -405,6 +424,7 @@ function initPlayer() {
|
|||||||
|
|
||||||
if (player) {
|
if (player) {
|
||||||
clearInterval(saveInterval);
|
clearInterval(saveInterval);
|
||||||
|
stopEngagementTracking();
|
||||||
player.destroy();
|
player.destroy();
|
||||||
}
|
}
|
||||||
initPlayer();
|
initPlayer();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<div class="p-5 md:p-7">
|
<div class="p-5 md:p-7">
|
||||||
@if($streamPage)
|
@if($streamPage)
|
||||||
<input id="e_id" type="hidden" value="{{ $episode->id }}" />
|
<input id="e_id" type="hidden" value="{{ $episode->id }}" />
|
||||||
|
<input id="auth_check" type="hidden" value="{{ auth()->check() ? '1' : '0' }}" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex flex-col gap-6 lg:flex-row">
|
<div class="flex flex-col gap-6 lg:flex-row">
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ Route::get('/v1/monthly-views', [HentaiApiController::class, 'getMonthlyViews'])
|
|||||||
Route::get('/hentai/{title}', [StreamController::class, 'index'])->name('hentai.index');
|
Route::get('/hentai/{title}', [StreamController::class, 'index'])->name('hentai.index');
|
||||||
Route::post('/player/api', [StreamApiController::class, 'getStream'])->name('hentai.player');
|
Route::post('/player/api', [StreamApiController::class, 'getStream'])->name('hentai.player');
|
||||||
Route::post('/watched/track', [StreamApiController::class, 'trackWatched'])->name('hentai.watched');
|
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
|
// Search
|
||||||
Route::get('/search', [HomeController::class, 'search'])->name('hentai.search');
|
Route::get('/search', [HomeController::class, 'search'])->name('hentai.search');
|
||||||
|
|||||||
Reference in New Issue
Block a user