mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-11 23:10:21 +00:00
init skip time tracker
This commit is contained in:
parent
9ed21823d0
commit
cfcca03d69
3 changed files with 327 additions and 2 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
149
src/components/player/hooks/useSkipTracking.ts
Normal file
149
src/components/player/hooks/useSkipTracking.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
173
src/components/player/internals/Backend/SkipTracker.tsx
Normal file
173
src/components/player/internals/Backend/SkipTracker.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue