From 1cdaed56255172fdfe1dc32088b40694f8c4f2aa Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 2 Nov 2025 10:54:35 -0700 Subject: [PATCH 01/11] add turnstile to skip api --- src/components/player/hooks/useSkipTime.ts | 11 +- src/utils/turnstile.ts | 160 +++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/utils/turnstile.ts diff --git a/src/components/player/hooks/useSkipTime.ts b/src/components/player/hooks/useSkipTime.ts index ebe2e0e4..8173490d 100644 --- a/src/components/player/hooks/useSkipTime.ts +++ b/src/components/player/hooks/useSkipTime.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { conf } from "@/setup/config"; import { usePreferencesStore } from "@/stores/preferences"; +import { getTurnstileToken } from "@/utils/turnstile"; // Thanks Nemo for this API const BASE_URL = "https://fed-skips.pstream.mov"; @@ -20,8 +21,16 @@ export function useSkipTime() { if (!febboxKey) return; try { + const turnstileToken = await getTurnstileToken( + "0x4AAAAAAB6ocCCpurfWRZyC", + ); + const apiUrl = `${BASE_URL}/${meta.imdbId}/${meta.season?.number}/${meta.episode?.number}`; - const response = await fetch(apiUrl); + const response = await fetch(apiUrl, { + headers: { + "cf-turnstile-response": turnstileToken, + }, + }); if (!response.ok) { if (response.status === 500 && retries < MAX_RETRIES) { diff --git a/src/utils/turnstile.ts b/src/utils/turnstile.ts new file mode 100644 index 00000000..0e16cf57 --- /dev/null +++ b/src/utils/turnstile.ts @@ -0,0 +1,160 @@ +/** + * Cloudflare Turnstile utility for handling invisible CAPTCHA verification + */ + +/** + * Loads the Cloudflare Turnstile script if not already loaded + */ +function loadTurnstileScript(): Promise { + return new Promise((resolve, reject) => { + // Check if Turnstile is already loaded + if ((window as any).turnstile) { + resolve(); + return; + } + + // Check if script is already being loaded + if ( + document.querySelector( + 'script[src*="challenges.cloudflare.com/turnstile"]', + ) + ) { + // Wait for it to load + const checkLoaded = () => { + if ((window as any).turnstile) { + resolve(); + } else { + setTimeout(checkLoaded, 100); + } + }; + checkLoaded(); + return; + } + + const script = document.createElement("script"); + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + script.async = true; + script.defer = true; + + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load Turnstile script")); + + document.head.appendChild(script); + }); +} + +/** + * Creates an invisible Turnstile widget and returns a promise that resolves with the token + * @param sitekey The Turnstile site key + * @param timeout Optional timeout in milliseconds (default: 30000) + * @returns Promise that resolves with the Turnstile token + */ +export async function getTurnstileToken( + sitekey: string, + timeout: number = 30000, +): Promise { + // Only run in browser environment + if (typeof window === "undefined") { + throw new Error("Turnstile verification requires browser environment"); + } + + try { + // Load Turnstile script + await loadTurnstileScript(); + + // Create a hidden container for the Turnstile widget + const container = document.createElement("div"); + container.style.position = "absolute"; + container.style.left = "-9999px"; + container.style.top = "-9999px"; + container.style.width = "1px"; + container.style.height = "1px"; + container.style.overflow = "hidden"; + container.style.opacity = "0"; + container.style.pointerEvents = "none"; + + document.body.appendChild(container); + + return new Promise((resolve, reject) => { + let widgetId: string | undefined; + let timeoutId: any; + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + if (widgetId && (window as any).turnstile) { + try { + (window as any).turnstile.remove(widgetId); + } catch (e) { + // Ignore errors during cleanup + } + } + if (container.parentNode) { + container.parentNode.removeChild(container); + } + }; + + // Set up timeout + timeoutId = setTimeout(() => { + cleanup(); + reject(new Error("Turnstile verification timed out")); + }, timeout); + + try { + // Render the Turnstile widget + widgetId = (window as any).turnstile.render(container, { + sitekey, + callback: (token: string) => { + cleanup(); + resolve(token); + }, + "error-callback": (error: string) => { + cleanup(); + reject(new Error(`Turnstile error: ${error}`)); + }, + "expired-callback": () => { + cleanup(); + reject(new Error("Turnstile token expired")); + }, + }); + } catch (error) { + cleanup(); + reject(new Error(`Failed to render Turnstile widget: ${error}`)); + } + }); + } catch (error) { + throw new Error(`Turnstile verification failed: ${error}`); + } +} + +/** + * Validates a Turnstile token by making a request to Cloudflare's verification endpoint + * @param token The Turnstile token to validate + * @param secret The Turnstile secret key (server-side only) + * @returns Promise that resolves with validation result + */ +export async function validateTurnstileToken( + token: string, + secret: string, +): Promise { + try { + const response = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + secret, + response: token, + }), + }, + ); + + const result = await response.json(); + return result.success === true; + } catch (error) { + console.error("Turnstile validation error:", error); + return false; + } +} From 82757248d5031c68901fea247b7080c3c34028dc Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 2 Nov 2025 11:06:19 -0700 Subject: [PATCH 02/11] update missing home section order setting syncer --- src/pages/Settings.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index af65bdab..c590282f 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -481,6 +481,7 @@ export function SettingsPage() { state.forceCompactEpisodeView.changed || state.enableLowPerformanceMode.changed || state.enableHoldToBoost.changed || + state.homeSectionOrder.changed || state.manualSourceSelection.changed || state.enableDoubleClickToSeek ) { @@ -505,6 +506,7 @@ export function SettingsPage() { forceCompactEpisodeView: state.forceCompactEpisodeView.state, enableLowPerformanceMode: state.enableLowPerformanceMode.state, enableHoldToBoost: state.enableHoldToBoost.state, + homeSectionOrder: state.homeSectionOrder.state, manualSourceSelection: state.manualSourceSelection.state, enableDoubleClickToSeek: state.enableDoubleClickToSeek.state, }); From bef85aa74149bdbe72cb07326dd1aea61d8ccba9 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 2 Nov 2025 11:38:41 -0700 Subject: [PATCH 03/11] let me hold to widescreen on bigger screens --- src/components/player/Player.tsx | 1 + src/pages/parts/player/PlayerPart.tsx | 19 +++++++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/player/Player.tsx b/src/components/player/Player.tsx index 89ec5cde..3df4d517 100644 --- a/src/components/player/Player.tsx +++ b/src/components/player/Player.tsx @@ -12,3 +12,4 @@ export * from "./internals/BookmarkButton"; export * from "./internals/InfoButton"; export * from "./internals/SkipEpisodeButton"; export * from "./atoms/Chromecast"; +export * from "./atoms/Widescreen"; diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index df502218..72a348d0 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -5,7 +5,6 @@ import { Player } from "@/components/player"; import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton"; import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay"; import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus"; -import { Widescreen } from "@/components/player/atoms/Widescreen"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; import { useSkipTime } from "@/components/player/hooks/useSkipTime"; import { useIsMobile } from "@/hooks/useIsMobile"; @@ -185,14 +184,10 @@ export function PlayerPart(props: PlayerPartProps) { ) : null} - {/* Fullscreen on when not shifting */} - {!isShifting && } - - {/* Expand button visible when shifting */} - {isShifting && ( -
- -
+ {isShifting || isHoldingFullscreen ? ( + + ) : ( + )} @@ -221,7 +216,11 @@ export function PlayerPart(props: PlayerPartProps) { className="select-none touch-none" style={{ WebkitTapHighlightColor: "transparent" }} > - {isHoldingFullscreen ? : } + {isHoldingFullscreen ? ( + + ) : ( + + )} )} From 80d2ae13bd179450d1f22cfc8371dcd0f138963b Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:57:31 -0700 Subject: [PATCH 04/11] fix watchparty join shortcut on home page --- src/hooks/useWatchPartySync.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/hooks/useWatchPartySync.ts b/src/hooks/useWatchPartySync.ts index f69839d1..2a88ab71 100644 --- a/src/hooks/useWatchPartySync.ts +++ b/src/hooks/useWatchPartySync.ts @@ -82,6 +82,13 @@ export function useWatchPartySync( // Get watch party state const { roomCode, isHost, enabled, enableAsGuest } = useWatchPartyStore(); + // Reset URL parameter checking when watch party is disabled + useEffect(() => { + if (!enabled) { + syncStateRef.current.checkedUrlParams = false; + } + }, [enabled]); + // Check URL parameters for watch party code useEffect(() => { if (syncStateRef.current.checkedUrlParams) return; From 1a5b55fcb59ccb644f25aadd246f83002fcbced1 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:57:37 -0700 Subject: [PATCH 05/11] update providers --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b6177b2..831c76bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 1.8.0 '@p-stream/providers': specifier: github:p-stream/providers#production - version: https://codeload.github.com/p-stream/providers/tar.gz/f86bacfb657781183c5ffc4e2dc68665cebc21bc + version: https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957 '@plasmohq/messaging': specifier: ^0.6.2 version: 0.6.2(react@18.3.1) @@ -1207,8 +1207,8 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/f86bacfb657781183c5ffc4e2dc68665cebc21bc': - resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/f86bacfb657781183c5ffc4e2dc68665cebc21bc} + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957': + resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957} version: 3.2.0 '@pkgjs/parseargs@0.11.0': @@ -5524,7 +5524,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/f86bacfb657781183c5ffc4e2dc68665cebc21bc': + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/f8c0aa098c7a68e325c7af23b4175cf323c69957': dependencies: abort-controller: 3.0.0 cheerio: 1.0.0-rc.12 From 9e1aa5e9a915cedb3c9c0f8b78876a81e4a02777 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 3 Nov 2025 22:49:12 -0700 Subject: [PATCH 06/11] Revert hide settings button when not playing --- src/pages/parts/player/PlayerPart.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 72a348d0..3295b9cf 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -179,11 +179,9 @@ export function PlayerPart(props: PlayerPartProps) { ) : null} {status === playerStatus.PLAYBACK_ERROR || status === playerStatus.PLAYING ? ( - <> - - - + ) : null} + {isShifting || isHoldingFullscreen ? ( ) : ( @@ -200,13 +198,11 @@ export function PlayerPart(props: PlayerPartProps) { )} {status === playerStatus.PLAYING ? ( - <> -
- -
- - +
+ +
) : null} +
{status === playerStatus.PLAYING && ( From 7643b719ca68ea3377cc5c4d53a5e2a9070204a9 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 3 Nov 2025 22:58:18 -0700 Subject: [PATCH 07/11] Prettier --- src/pages/parts/player/PlayerPart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 3295b9cf..d0eb236e 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -181,7 +181,7 @@ export function PlayerPart(props: PlayerPartProps) { status === playerStatus.PLAYING ? ( ) : null} - + {isShifting || isHoldingFullscreen ? ( ) : ( @@ -202,7 +202,7 @@ export function PlayerPart(props: PlayerPartProps) {
) : null} - +
{status === playerStatus.PLAYING && ( From f74c4aca42e7c13a7ebed06fd38849c38af36667 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:12:08 -0700 Subject: [PATCH 08/11] show HD right away from release api --- .../detailsModal/components/sections/DetailsBody.tsx | 8 ++------ src/pages/discover/components/FeaturedCarousel.tsx | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/overlays/detailsModal/components/sections/DetailsBody.tsx b/src/components/overlays/detailsModal/components/sections/DetailsBody.tsx index ed2259f9..e229e60f 100644 --- a/src/components/overlays/detailsModal/components/sections/DetailsBody.tsx +++ b/src/components/overlays/detailsModal/components/sections/DetailsBody.tsx @@ -103,10 +103,8 @@ export function DetailsBody({ if (hasDigitalRelease) { const digitalReleaseDate = new Date(releaseInfo.digital_release_date!); - const twoDaysAfter = new Date(digitalReleaseDate); - twoDaysAfter.setDate(twoDaysAfter.getDate() + 2); - if (new Date() >= twoDaysAfter) { + if (new Date() >= digitalReleaseDate) { return HD; } } @@ -115,10 +113,8 @@ export function DetailsBody({ const theatricalReleaseDate = new Date( releaseInfo.theatrical_release_date!, ); - const fortyFiveDaysAfter = new Date(theatricalReleaseDate); - fortyFiveDaysAfter.setDate(fortyFiveDaysAfter.getDate() + 45); - if (new Date() >= fortyFiveDaysAfter) { + if (new Date() >= theatricalReleaseDate) { return (
HD diff --git a/src/pages/discover/components/FeaturedCarousel.tsx b/src/pages/discover/components/FeaturedCarousel.tsx index 90287d1f..c3ac8a7b 100644 --- a/src/pages/discover/components/FeaturedCarousel.tsx +++ b/src/pages/discover/components/FeaturedCarousel.tsx @@ -573,10 +573,8 @@ export function FeaturedCarousel({ if (hasDigitalRelease) { const digitalReleaseDate = new Date(releaseInfo.digital_release_date!); - const twoDaysAfter = new Date(digitalReleaseDate); - twoDaysAfter.setDate(twoDaysAfter.getDate() + 2); - if (new Date() >= twoDaysAfter) { + if (new Date() >= digitalReleaseDate) { return HD; } } @@ -585,10 +583,8 @@ export function FeaturedCarousel({ const theatricalReleaseDate = new Date( releaseInfo.theatrical_release_date!, ); - const fortyFiveDaysAfter = new Date(theatricalReleaseDate); - fortyFiveDaysAfter.setDate(fortyFiveDaysAfter.getDate() + 45); - if (new Date() >= fortyFiveDaysAfter) { + if (new Date() >= theatricalReleaseDate) { return (
HD From ecb5684529f3aa64158434ec702e4c08dda72689 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:09:00 -0700 Subject: [PATCH 09/11] fix typo --- src/setup/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/setup/config.ts b/src/setup/config.ts index bf41548a..42b00a58 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -28,8 +28,8 @@ interface Config { ALLOW_FEBBOX_KEY: boolean; ALLOW_REAL_DEBRID_KEY: boolean; SHOW_AD: boolean; - AD_CONTENT_URL: string; // like - TRACK_SCRIPT: string; + AD_CONTENT_URL: string; + TRACK_SCRIPT: string; // like BANNER_MESSAGE: string; BANNER_ID: string; } From 7d5c88c0a17d39c340b841749f4bc57ce5a62c4a Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:14:37 -0700 Subject: [PATCH 10/11] log skips to new api --- .../player/atoms/SkipIntroButton.tsx | 41 +++- .../player/hooks/useSkipTracking.ts | 119 ++++++----- .../player/internals/Backend/SkipTracker.tsx | 189 ++++-------------- 3 files changed, 152 insertions(+), 197 deletions(-) diff --git a/src/components/player/atoms/SkipIntroButton.tsx b/src/components/player/atoms/SkipIntroButton.tsx index 4f8f08d6..b743622a 100644 --- a/src/components/player/atoms/SkipIntroButton.tsx +++ b/src/components/player/atoms/SkipIntroButton.tsx @@ -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; diff --git a/src/components/player/hooks/useSkipTracking.ts b/src/components/player/hooks/useSkipTracking.ts index e4747da8..c22253a0 100644 --- a/src/components/player/hooks/useSkipTracking.ts +++ b/src/components/player/hooks/useSkipTracking.ts @@ -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([]); const previousTimeRef = useRef(0); - const lastUpdateTimeRef = useRef(0); - const isSeekingRef = useRef(false); + const skipWindowRef = useRef>([]); + const isInSkipSessionRef = useRef(false); + const skipSessionStartRef = useRef(0); + const sessionTotalRef = useRef(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(() => { diff --git a/src/components/player/internals/Backend/SkipTracker.tsx b/src/components/player/internals/Backend/SkipTracker.tsx index 4b28ce08..b3000264 100644 --- a/src/components/player/internals/Backend/SkipTracker.tsx +++ b/src/components/player/internals/Backend/SkipTracker.tsx @@ -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(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; } From 9bb54ee17e824df9c807bc3d38ecd8fb52e8d5c9 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:23:28 -0700 Subject: [PATCH 11/11] fix manual source selection resetting progress --- .../player/hooks/useSourceSelection.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/components/player/hooks/useSourceSelection.ts b/src/components/player/hooks/useSourceSelection.ts index 428096f6..aa5cf1d0 100644 --- a/src/components/player/hooks/useSourceSelection.ts +++ b/src/components/player/hooks/useSourceSelection.ts @@ -22,6 +22,21 @@ import { convertRunoutputToSource } from "@/components/player/utils/convertRunou import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { metaToScrapeMedia } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; +import { usePreferencesStore } from "@/stores/preferences"; +import { useProgressStore } from "@/stores/progress"; + +function getSavedProgress(items: Record, meta: any): number { + const item = items[meta?.tmdbId ?? ""]; + if (!item || !meta) return 0; + if (meta.type === "movie") { + if (!item.progress) return 0; + return item.progress.watched; + } + + const ep = item.episodes[meta.episode?.tmdbId ?? ""]; + if (!ep) return 0; + return ep.progress.watched; +} export function useEmbedScraping( routerId: string, @@ -33,8 +48,8 @@ export function useEmbedScraping( const setCaption = usePlayerStore((s) => s.setCaption); const setSourceId = usePlayerStore((s) => s.setSourceId); const setEmbedId = usePlayerStore((s) => (s as any).setEmbedId); - const progress = usePlayerStore((s) => s.progress.time); const meta = usePlayerStore((s) => s.meta); + const progressItems = useProgressStore((s) => s.items); const router = useOverlayRouter(routerId); const { report } = useReportProviders(); @@ -81,7 +96,7 @@ export function useEmbedScraping( setSource( convertRunoutputToSource({ stream: result.stream[0] }), convertProviderCaption(result.stream[0].captions), - progress, + getSavedProgress(progressItems, meta), ); router.close(); }, [embedId, sourceId, meta, router, report, setCaption]); @@ -99,7 +114,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) { const setCaption = usePlayerStore((s) => s.setCaption); const setSourceId = usePlayerStore((s) => s.setSourceId); const setEmbedId = usePlayerStore((s) => (s as any).setEmbedId); - const progress = usePlayerStore((s) => s.progress.time); + const progressItems = useProgressStore((s) => s.items); const router = useOverlayRouter(routerId); const { report } = useReportProviders(); @@ -144,7 +159,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) { setSource( convertRunoutputToSource({ stream: result.stream[0] }), convertProviderCaption(result.stream[0].captions), - progress, + getSavedProgress(progressItems, meta), ); setSourceId(sourceId); router.close(); @@ -201,7 +216,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) { setSource( convertRunoutputToSource({ stream: embedResult.stream[0] }), convertProviderCaption(embedResult.stream[0].captions), - progress, + getSavedProgress(progressItems, meta), ); router.close(); }