log skips to new api

This commit is contained in:
Pas 2025-11-05 14:14:37 -07:00
parent ecb5684529
commit 7d5c88c0a1
3 changed files with 152 additions and 197 deletions

View file

@ -3,6 +3,7 @@ import { useCallback } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/utils/Transition";
import { useAuthStore } from "@/stores/auth";
import { usePlayerStore } from "@/stores/player/store";
function shouldShowSkipButton(
@ -47,6 +48,8 @@ export function SkipIntroButton(props: {
const time = usePlayerStore((s) => s.progress.time);
const status = usePlayerStore((s) => s.status);
const display = usePlayerStore((s) => s.display);
const meta = usePlayerStore((s) => s.meta);
const account = useAuthStore((s) => s.account);
const showingState = shouldShowSkipButton(time, props.skipTime);
const animation = showingState === "hover" ? "slide-up" : "fade";
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
@ -55,11 +58,47 @@ export function SkipIntroButton(props: {
? bottom
: "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
}
const sendSkipAnalytics = useCallback(
async (startTime: number, endTime: number, skipDuration: number) => {
try {
await fetch("https://skips.pstream.mov/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start_time: startTime,
end_time: endTime,
skip_duration: skipDuration,
content_id: meta?.tmdbId,
content_type: meta?.type,
season_id: meta?.season?.tmdbId,
episode_id: meta?.episode?.tmdbId,
user_id: account?.userId,
session_id: `session_${Date.now()}`,
turnstile_token: "",
}),
});
} catch (error) {
console.error("Failed to send skip analytics:", error);
}
},
[meta, account],
);
const handleSkip = useCallback(() => {
if (typeof props.skipTime === "number" && display) {
const startTime = time;
const endTime = props.skipTime;
const skipDuration = endTime - startTime;
display.setTime(props.skipTime);
// Send analytics for intro skip button usage
// eslint-disable-next-line no-console
console.log(`Skip intro button used: ${skipDuration}s total`);
sendSkipAnalytics(startTime, endTime, skipDuration);
}
}, [props.skipTime, display]);
}, [props.skipTime, display, time, sendSkipAnalytics]);
if (!props.inControl) return null;
let show = false;

View file

@ -26,77 +26,102 @@ interface SkipTrackingResult {
}
/**
* Hook that tracks when users skip or scrub more than 15 seconds
* Useful for gathering information about show intros and user behavior
* Hook that tracks rapid skipping sessions where users accumulate 30+ seconds of forward
* movement within a 5-second window. Sessions continue until 8 seconds pass without
* any forward movement, then report the total skip distance. Ignores skips that start
* after 20% of video duration (unlikely to be intro skipping).
*
* @param minSkipThreshold Minimum skip duration in seconds to track (default: 15)
* @param minSkipThreshold Minimum total forward movement in 5-second window to start session (default: 30)
* @param maxHistory Maximum number of skip events to keep in history (default: 50)
*/
export function useSkipTracking(
minSkipThreshold: number = 15,
minSkipThreshold: number = 30,
maxHistory: number = 50,
): SkipTrackingResult {
const [skipHistory, setSkipHistory] = useState<SkipEvent[]>([]);
const previousTimeRef = useRef<number>(0);
const lastUpdateTimeRef = useRef<number>(0);
const isSeekingRef = useRef<boolean>(false);
const skipWindowRef = useRef<Array<{ time: number; delta: number }>>([]);
const isInSkipSessionRef = useRef<boolean>(false);
const skipSessionStartRef = useRef<number>(0);
const sessionTotalRef = useRef<number>(0);
// 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 duration = progress.duration;
const clearHistory = useCallback(() => {
setSkipHistory([]);
previousTimeRef.current = 0;
lastUpdateTimeRef.current = 0;
skipWindowRef.current = [];
isInSkipSessionRef.current = false;
skipSessionStartRef.current = 0;
sessionTotalRef.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;
// Track forward movements >= 1 second in sliding 5-second window
if (timeDelta >= 1) {
// Add forward movement to window and remove entries older than 5 seconds
skipWindowRef.current.push({ time: now, delta: timeDelta });
skipWindowRef.current = skipWindowRef.current.filter(
(entry) => entry.time > now - 5000,
);
// 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);
// Calculate total forward movement in current window
const totalForwardMovement = skipWindowRef.current.reduce(
(sum, entry) => sum + entry.delta,
0,
);
// 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) {
// Start session when threshold exceeded
if (
totalForwardMovement >= minSkipThreshold &&
!isInSkipSessionRef.current
) {
isInSkipSessionRef.current = true;
skipSessionStartRef.current = previousTimeRef.current;
sessionTotalRef.current = totalForwardMovement;
}
// Update session total while active
else if (isInSkipSessionRef.current) {
sessionTotalRef.current = totalForwardMovement;
}
}
// End session if no forward movement in last 8 seconds
const recentEntries = skipWindowRef.current.filter(
(entry) => entry.time > now - 8000,
);
if (isInSkipSessionRef.current && recentEntries.length === 0) {
// Ignore skips that start after 20% of video duration (likely not intro skipping)
const twentyPercentMark = duration * 0.2;
if (skipSessionStartRef.current > twentyPercentMark) {
// Reset session state without creating event
isInSkipSessionRef.current = false;
skipSessionStartRef.current = 0;
sessionTotalRef.current = 0;
skipWindowRef.current = [];
return;
}
// Create skip event for completed session
const skipEvent: SkipEvent = {
startTime: previousTimeRef.current,
startTime: skipSessionStartRef.current,
endTime: currentTime,
skipDuration: timeDelta,
skipDuration: sessionTotalRef.current,
timestamp: now,
meta: meta
? {
@ -118,22 +143,22 @@ export function useSkipTracking(
? newHistory.slice(newHistory.length - maxHistory)
: newHistory;
});
// Reset session state
isInSkipSessionRef.current = false;
skipSessionStartRef.current = 0;
sessionTotalRef.current = 0;
skipWindowRef.current = [];
}
previousTimeRef.current = currentTime;
lastUpdateTimeRef.current = now;
}, [progress.time, mediaPlaying, meta, minSkipThreshold, maxHistory]);
}, [progress.time, duration, meta, minSkipThreshold, maxHistory]);
useEffect(() => {
// Run detection every second when video is playing
const interval = setInterval(() => {
if (mediaPlaying.hasPlayedOnce) {
detectSkip();
}
}, 1000);
// Monitor time changes every 100ms to catch rapid skipping
const interval = setInterval(detectSkip, 100);
return () => clearInterval(interval);
}, [detectSkip, mediaPlaying.hasPlayedOnce]);
}, [detectSkip]);
// Reset tracking when content changes
useEffect(() => {

View file

@ -1,173 +1,64 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useSkipTracking } from "@/components/player/hooks/useSkipTracking";
import { useAuthStore } from "@/stores/auth";
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
* Component that tracks and reports completed skip sessions to analytics backend.
* Sessions are detected when users accumulate 30+ seconds of forward movement
* within a 5-second window and end after 8 seconds of no activity.
* Ignores skips that start after 20% of video duration (unlikely to be intro skipping).
*/
export function SkipTracker() {
const { skipHistory, latestSkip } = useSkipTracking(15); // Track skips > 15 seconds
const { latestSkip } = useSkipTracking(30);
const lastLoggedSkipRef = useRef<number>(0);
// Player metadata for context
const meta = usePlayerStore((s) => s.meta);
const account = useAuthStore((s) => s.account);
const turnstileToken = "";
const sendSkipAnalytics = useCallback(async () => {
if (!latestSkip) return;
try {
await fetch("https://skips.pstream.mov/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start_time: latestSkip.startTime,
end_time: latestSkip.endTime,
skip_duration: latestSkip.skipDuration,
content_id: meta?.tmdbId,
content_type: meta?.type,
season_id: meta?.season?.tmdbId,
episode_id: meta?.episode?.tmdbId,
user_id: account?.userId,
session_id: `session_${Date.now()}`,
turnstile_token: turnstileToken ?? "",
}),
});
} catch (error) {
console.error("Failed to send skip analytics:", error);
}
}, [latestSkip, meta, account]);
useEffect(() => {
if (!latestSkip || !meta) return;
// Avoid logging the same skip multiple times
// Avoid processing 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
// Log completed skip session
// 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),
console.log(`Skip session completed: ${latestSkip.skipDuration}s total`);
// 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",
});
}
// Send analytics data to backend
sendSkipAnalytics();
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]);
*/
}, [latestSkip, meta, sendSkipAnalytics]);
return null;
}