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; +}