mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-20 06:52:05 +00:00
log skips to new api
This commit is contained in:
parent
ecb5684529
commit
7d5c88c0a1
3 changed files with 152 additions and 197 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue