From cfcca03d69e3e2479b7593cd6af7c4402fdb84c7 Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Thu, 10 Jul 2025 11:33:33 -0600
Subject: [PATCH] init skip time tracker
---
src/components/player/base/Container.tsx | 7 +-
.../player/hooks/useSkipTracking.ts | 149 +++++++++++++++
.../player/internals/Backend/SkipTracker.tsx | 173 ++++++++++++++++++
3 files changed, 327 insertions(+), 2 deletions(-)
create mode 100644 src/components/player/hooks/useSkipTracking.ts
create mode 100644 src/components/player/internals/Backend/SkipTracker.tsx
diff --git a/src/components/player/base/Container.tsx b/src/components/player/base/Container.tsx
index 762e95be..9f37b87d 100644
--- a/src/components/player/base/Container.tsx
+++ b/src/components/player/base/Container.tsx
@@ -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) {
-
+
+
diff --git a/src/components/player/hooks/useSkipTracking.ts b/src/components/player/hooks/useSkipTracking.ts
new file mode 100644
index 00000000..e4747da8
--- /dev/null
+++ b/src/components/player/hooks/useSkipTracking.ts
@@ -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([]);
+ const previousTimeRef = useRef(0);
+ const lastUpdateTimeRef = useRef(0);
+ const isSeekingRef = useRef(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,
+ };
+}
diff --git a/src/components/player/internals/Backend/SkipTracker.tsx b/src/components/player/internals/Backend/SkipTracker.tsx
new file mode 100644
index 00000000..4b28ce08
--- /dev/null
+++ b/src/components/player/internals/Backend/SkipTracker.tsx
@@ -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(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;
+}