Merge branch 'p-stream:production' into production

This commit is contained in:
zisra 2025-11-09 13:43:00 +08:00 committed by GitHub
commit 077e84c12d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 377 additions and 241 deletions

View file

@ -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

View file

@ -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 <span className="text-green-400">HD</span>;
}
}
@ -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 (
<div className="px-2 py-1 rounded-lg backdrop-blur-sm bg-gray-600/40">
<span className="text-green-400">HD</span>

View file

@ -12,3 +12,4 @@ export * from "./internals/BookmarkButton";
export * from "./internals/InfoButton";
export * from "./internals/SkipEpisodeButton";
export * from "./atoms/Chromecast";
export * from "./atoms/Widescreen";

View file

@ -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;

View file

@ -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) {

View file

@ -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(() => {

View file

@ -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<string, any>, 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();
}

View file

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

View file

@ -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;

View file

@ -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,
});

View file

@ -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 <span className="text-green-400">HD</span>;
}
}
@ -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 (
<div className="px-2 py-1 rounded-lg backdrop-blur-sm bg-gray-600/40">
<span className="text-green-400">HD</span>

View file

@ -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";
@ -180,19 +179,13 @@ export function PlayerPart(props: PlayerPartProps) {
) : null}
{status === playerStatus.PLAYBACK_ERROR ||
status === playerStatus.PLAYING ? (
<>
<Player.Captions />
<Player.Settings />
</>
<Player.Captions />
) : null}
{/* Fullscreen on when not shifting */}
{!isShifting && <Player.Fullscreen />}
{/* Expand button visible when shifting */}
{isShifting && (
<div>
<Widescreen />
</div>
<Player.Settings />
{isShifting || isHoldingFullscreen ? (
<Player.Widescreen />
) : (
<Player.Fullscreen />
)}
</div>
</div>
@ -205,13 +198,11 @@ export function PlayerPart(props: PlayerPartProps) {
)}
<Player.Episodes inControl={inControl} />
{status === playerStatus.PLAYING ? (
<>
<div className="hidden ssm:block">
<Player.Captions />
</div>
<Player.Settings />
</>
<div className="hidden ssm:block">
<Player.Captions />
</div>
) : null}
<Player.Settings />
</div>
<div>
{status === playerStatus.PLAYING && (
@ -221,7 +212,11 @@ export function PlayerPart(props: PlayerPartProps) {
className="select-none touch-none"
style={{ WebkitTapHighlightColor: "transparent" }}
>
{isHoldingFullscreen ? <Widescreen /> : <Player.Fullscreen />}
{isHoldingFullscreen ? (
<Player.Widescreen />
) : (
<Player.Fullscreen />
)}
</div>
)}
</div>

View file

@ -28,8 +28,8 @@ interface Config {
ALLOW_FEBBOX_KEY: boolean;
ALLOW_REAL_DEBRID_KEY: boolean;
SHOW_AD: boolean;
AD_CONTENT_URL: string; // like <script src="https://umami.com/script.js"></script>
TRACK_SCRIPT: string;
AD_CONTENT_URL: string;
TRACK_SCRIPT: string; // like <script src="https://umami.com/script.js"></script>
BANNER_MESSAGE: string;
BANNER_ID: string;
}

160
src/utils/turnstile.ts Normal file
View file

@ -0,0 +1,160 @@
/**
* Cloudflare Turnstile utility for handling invisible CAPTCHA verification
*/
/**
* Loads the Cloudflare Turnstile script if not already loaded
*/
function loadTurnstileScript(): Promise<void> {
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<string> {
// 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<string>((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<boolean> {
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;
}
}