init skip time tracker

This commit is contained in:
Pas 2025-07-10 11:33:33 -06:00
parent 9ed21823d0
commit cfcca03d69
3 changed files with 327 additions and 2 deletions

View file

@ -1,6 +1,7 @@
import { ReactNode, RefObject, useEffect, useRef } from "react";
import { OverlayDisplay } from "@/components/overlays/OverlayDisplay";
import { SkipTracker } from "@/components/player/internals/Backend/SkipTracker";
import { CastingInternal } from "@/components/player/internals/CastingInternal";
import { HeadUpdater } from "@/components/player/internals/HeadUpdater";
import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents";
@ -11,10 +12,11 @@ import { ThumbnailScraper } from "@/components/player/internals/ThumbnailScraper
import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget";
import { VideoContainer } from "@/components/player/internals/VideoContainer";
import { WatchPartyResetter } from "@/components/player/internals/WatchPartyResetter";
import { WebhookReporter } from "@/components/player/internals/WebhookReporter";
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
import { WatchPartyReporter } from "../internals/Backend/WatchPartyReporter";
export interface PlayerProps {
children?: ReactNode;
showingControls: boolean;
@ -95,7 +97,8 @@ export function Container(props: PlayerProps) {
<ProgressSaver />
<KeyboardEvents />
<MediaSession />
<WebhookReporter />
<WatchPartyReporter />
<SkipTracker />
<WatchPartyResetter />
<div className="relative h-screen overflow-hidden">
<VideoClickTarget showingControls={props.showingControls} />

View file

@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { usePlayerStore } from "@/stores/player/store";
interface SkipEvent {
startTime: number;
endTime: number;
skipDuration: number;
timestamp: number;
meta?: {
title: string;
type: string;
tmdbId?: string;
seasonNumber?: number;
episodeNumber?: number;
};
}
interface SkipTrackingResult {
/** Array of skip events detected */
skipHistory: SkipEvent[];
/** The most recent skip event */
latestSkip: SkipEvent | null;
/** Clear the skip history */
clearHistory: () => void;
}
/**
* Hook that tracks when users skip or scrub more than 15 seconds
* Useful for gathering information about show intros and user behavior
*
* @param minSkipThreshold Minimum skip duration in seconds to track (default: 15)
* @param maxHistory Maximum number of skip events to keep in history (default: 50)
*/
export function useSkipTracking(
minSkipThreshold: number = 15,
maxHistory: number = 50,
): SkipTrackingResult {
const [skipHistory, setSkipHistory] = useState<SkipEvent[]>([]);
const previousTimeRef = useRef<number>(0);
const lastUpdateTimeRef = useRef<number>(0);
const isSeekingRef = useRef<boolean>(false);
// Get current player state
const progress = usePlayerStore((s) => s.progress);
const mediaPlaying = usePlayerStore((s) => s.mediaPlaying);
const meta = usePlayerStore((s) => s.meta);
const isSeeking = usePlayerStore((s) => s.interface.isSeeking);
// Track seeking state to avoid false positives during drag seeking
useEffect(() => {
isSeekingRef.current = isSeeking;
}, [isSeeking]);
const clearHistory = useCallback(() => {
setSkipHistory([]);
previousTimeRef.current = 0;
lastUpdateTimeRef.current = 0;
}, []);
const detectSkip = useCallback(() => {
const now = Date.now();
const currentTime = progress.time;
// Don't track if video hasn't started playing or if we're actively seeking
if (!mediaPlaying.hasPlayedOnce || isSeekingRef.current) {
previousTimeRef.current = currentTime;
lastUpdateTimeRef.current = now;
return;
}
// Initialize on first run
if (previousTimeRef.current === 0) {
previousTimeRef.current = currentTime;
lastUpdateTimeRef.current = now;
return;
}
const timeDelta = currentTime - previousTimeRef.current;
const realTimeDelta = now - lastUpdateTimeRef.current;
// Calculate expected time change based on playback rate and real time passed
const expectedTimeDelta = mediaPlaying.isPlaying
? (realTimeDelta / 1000) * mediaPlaying.playbackRate
: 0;
// Detect if the time jump is significantly different from expected
// This accounts for normal playback, pausing, and small buffering hiccups
const unexpectedJump = Math.abs(timeDelta - expectedTimeDelta);
// Only consider it a skip if:
// 1. The time jump is greater than our threshold
// 2. The unexpected jump is significant (more than 3 seconds difference from expected)
// 3. We're not in a seeking state
if (Math.abs(timeDelta) >= minSkipThreshold && unexpectedJump >= 3) {
const skipEvent: SkipEvent = {
startTime: previousTimeRef.current,
endTime: currentTime,
skipDuration: timeDelta,
timestamp: now,
meta: meta
? {
title:
meta.type === "show" && meta.episode
? `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}`
: meta.title,
type: meta.type === "movie" ? "Movie" : "TV Show",
tmdbId: meta.tmdbId,
seasonNumber: meta.season?.number,
episodeNumber: meta.episode?.number,
}
: undefined,
};
setSkipHistory((prev) => {
const newHistory = [...prev, skipEvent];
return newHistory.length > maxHistory
? newHistory.slice(newHistory.length - maxHistory)
: newHistory;
});
}
previousTimeRef.current = currentTime;
lastUpdateTimeRef.current = now;
}, [progress.time, mediaPlaying, meta, minSkipThreshold, maxHistory]);
useEffect(() => {
// Run detection every second when video is playing
const interval = setInterval(() => {
if (mediaPlaying.hasPlayedOnce) {
detectSkip();
}
}, 1000);
return () => clearInterval(interval);
}, [detectSkip, mediaPlaying.hasPlayedOnce]);
// Reset tracking when content changes
useEffect(() => {
clearHistory();
}, [meta?.tmdbId, clearHistory]);
return {
skipHistory,
latestSkip:
skipHistory.length > 0 ? skipHistory[skipHistory.length - 1] : null,
clearHistory,
};
}

View file

@ -0,0 +1,173 @@
import { useEffect, useRef } from "react";
import { useSkipTracking } from "@/components/player/hooks/useSkipTracking";
import { usePlayerStore } from "@/stores/player/store";
/**
* Component that tracks when users skip or scrub more than 15 seconds
* Useful for gathering information about show intros and user behavior patterns
* Currently logs to console - can be extended to send to backend analytics endpoint
*/
export function SkipTracker() {
const { skipHistory, latestSkip } = useSkipTracking(15); // Track skips > 15 seconds
const lastLoggedSkipRef = useRef<number>(0);
// Player metadata for context
const meta = usePlayerStore((s) => s.meta);
useEffect(() => {
if (!latestSkip || !meta) return;
// Avoid logging the same skip multiple times
if (latestSkip.timestamp === lastLoggedSkipRef.current) return;
// Format skip duration for readability
const formatDuration = (seconds: number): string => {
const absSeconds = Math.abs(seconds);
const minutes = Math.floor(absSeconds / 60);
const remainingSeconds = Math.floor(absSeconds % 60);
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${remainingSeconds}s`;
};
// Format time position for readability
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
const skipDirection = latestSkip.skipDuration > 0 ? "forward" : "backward";
const skipType = Math.abs(latestSkip.skipDuration) >= 30 ? "scrub" : "skip";
// Log the skip event with detailed information
// eslint-disable-next-line no-console
console.log(`User ${skipType.toUpperCase()} detected`, {
// Basic skip info
direction: skipDirection,
duration: formatDuration(latestSkip.skipDuration),
from: formatTime(latestSkip.startTime),
to: formatTime(latestSkip.endTime),
// Content context
content: {
title: latestSkip.meta?.title || "Unknown",
type: latestSkip.meta?.type || "Unknown",
tmdbId: latestSkip.meta?.tmdbId,
},
// Episode context (for TV shows)
...(meta.type === "show" && {
episode: {
season: latestSkip.meta?.seasonNumber,
episode: latestSkip.meta?.episodeNumber,
},
}),
// Analytics data that could be sent to backend
analytics: {
timestamp: new Date(latestSkip.timestamp).toISOString(),
startTime: latestSkip.startTime,
endTime: latestSkip.endTime,
skipDuration: latestSkip.skipDuration,
contentId: latestSkip.meta?.tmdbId,
contentType: latestSkip.meta?.type,
seasonId: meta.season?.tmdbId,
episodeId: meta.episode?.tmdbId,
},
});
// Log special cases that might indicate intro skipping
if (
meta.type === "show" &&
latestSkip.startTime <= 30 && // Skip happened in first 30 seconds
latestSkip.skipDuration > 15 && // Forward skip of at least 15 seconds
latestSkip.skipDuration <= 120 // But not more than 2 minutes (reasonable intro length)
) {
// eslint-disable-next-line no-console
console.log(`Potential intro skip dete`, {
show: latestSkip.meta?.title,
season: latestSkip.meta?.seasonNumber,
episode: latestSkip.meta?.episodeNumber,
introSkipDuration: formatDuration(latestSkip.skipDuration),
message: "User likely skipped intro sequence",
});
}
// Log potential outro/credits skipping
const progress = usePlayerStore.getState().progress;
const timeRemaining = progress.duration - latestSkip.endTime;
if (
latestSkip.skipDuration > 0 && // Forward skip
timeRemaining <= 300 && // Within last 5 minutes
latestSkip.skipDuration >= 15
) {
// eslint-disable-next-line no-console
console.log(`Potential outro skip detected`, {
content: latestSkip.meta?.title,
timeRemaining: formatDuration(timeRemaining),
skipDuration: formatDuration(latestSkip.skipDuration),
message: "User likely skipped credits/outro",
});
}
lastLoggedSkipRef.current = latestSkip.timestamp;
}, [latestSkip, meta]);
// Log summary statistics occasionally... just for testing, we likely wont use it unless useful.
useEffect(() => {
if (skipHistory.length > 0 && skipHistory.length % 5 === 0) {
const forwardSkips = skipHistory.filter((s) => s.skipDuration > 0);
const backwardSkips = skipHistory.filter((s) => s.skipDuration < 0);
const avgSkipDuration =
skipHistory.reduce((sum, s) => sum + Math.abs(s.skipDuration), 0) /
skipHistory.length;
// eslint-disable-next-line no-console
console.log(`skip analytics`, {
totalSkips: skipHistory.length,
forwardSkips: forwardSkips.length,
backwardSkips: backwardSkips.length,
averageSkipDuration: `${Math.round(avgSkipDuration)}s`,
content: meta?.title || "Unknown",
});
}
}, [skipHistory.length, skipHistory, meta?.title]);
// TODO: When backend endpoint is ready, replace console.log with API calls
// Example implementation:
/*
useEffect(() => {
if (!latestSkip || !account?.userId) return;
// Send skip data to analytics endpoint
const sendSkipAnalytics = async () => {
try {
await fetch(`${backendUrl}/api/analytics/skips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: account.userId,
skipData: latestSkip,
contentContext: {
tmdbId: meta?.tmdbId,
type: meta?.type,
seasonId: meta?.season?.tmdbId,
episodeId: meta?.episode?.tmdbId,
}
})
});
} catch (error) {
console.error('Failed to send skip analytics:', error);
}
};
sendSkipAnalytics();
}, [latestSkip, account?.userId, meta]);
*/
return null;
}