diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index cc97e4e8..85910ed8 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -285,6 +285,7 @@ "randomCaption": "Select random caption from last used language", "syncSubtitlesEarlier": "Sync subtitles earlier (-0.5s)", "syncSubtitlesLater": "Sync subtitles later (+0.5s)", + "toggleNativeSubtitles": "Toggle native subtitles", "barrelRoll": "Do a barrel roll! 🌀", "closeOverlay": "Close overlay/modal", "nextEpisode": "Next episode", @@ -734,6 +735,10 @@ "device": "device", "enabled": "Casting to device 🎬" }, + "skipIntro": { + "feedback": "Was this skip helpful?", + "skip": "Skip Intro" + }, "menus": { "downloads": { "button": "Attempt download", diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 4a7da506..dba224d1 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -84,6 +84,8 @@ export enum Icons { REPEAT = "repeat", PLUS = "plus", TRANSLATE = "translate", + THUMBS_UP = "thumbsUp", + THUMBS_DOWN = "thumbsDown", } export interface IconProps { @@ -185,6 +187,8 @@ const iconList: Record = { repeat: ``, plus: ``, translate: ``, + thumbsUp: ``, + thumbsDown: ``, }; export const Icon = memo((props: IconProps) => { diff --git a/src/components/overlays/KeyboardCommandsEditModal.tsx b/src/components/overlays/KeyboardCommandsEditModal.tsx index 3d40e4c7..750042ff 100644 --- a/src/components/overlays/KeyboardCommandsEditModal.tsx +++ b/src/components/overlays/KeyboardCommandsEditModal.tsx @@ -165,6 +165,13 @@ const getShortcutGroups = ( "global.keyboardShortcuts.shortcuts.syncSubtitlesLater", ), }, + { + id: ShortcutId.TOGGLE_NATIVE_SUBTITLES, + config: shortcuts[ShortcutId.TOGGLE_NATIVE_SUBTITLES], + description: t( + "global.keyboardShortcuts.shortcuts.toggleNativeSubtitles", + ), + }, ], }, { diff --git a/src/components/overlays/KeyboardCommandsModal.tsx b/src/components/overlays/KeyboardCommandsModal.tsx index 8e93438e..aa6cd00b 100644 --- a/src/components/overlays/KeyboardCommandsModal.tsx +++ b/src/components/overlays/KeyboardCommandsModal.tsx @@ -189,6 +189,13 @@ const getShortcutGroups = ( ), config: getConfig(ShortcutId.SYNC_SUBTITLES_LATER), }, + { + key: getDisplayKey(ShortcutId.TOGGLE_NATIVE_SUBTITLES) || "S", + description: t( + "global.keyboardShortcuts.shortcuts.toggleNativeSubtitles", + ), + config: getConfig(ShortcutId.TOGGLE_NATIVE_SUBTITLES), + }, ], }, { diff --git a/src/components/player/atoms/SkipIntroButton.tsx b/src/components/player/atoms/SkipIntroButton.tsx index 9af7f43a..3b030f74 100644 --- a/src/components/player/atoms/SkipIntroButton.tsx +++ b/src/components/player/atoms/SkipIntroButton.tsx @@ -1,5 +1,6 @@ import classNames from "classnames"; -import { useCallback } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Icon, Icons } from "@/components/Icon"; import { useSkipTracking } from "@/components/player/hooks/useSkipTracking"; @@ -49,7 +50,15 @@ export function SkipIntroButton(props: { const status = usePlayerStore((s) => s.status); const display = usePlayerStore((s) => s.display); const meta = usePlayerStore((s) => s.meta); - const { addSkipEvent } = useSkipTracking(30); + const { addSkipEvent } = useSkipTracking(20); + const [showFeedback, setShowFeedback] = useState(false); + const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); + const timeoutRef = useRef | null>(null); + const pendingSkipDataRef = useRef<{ + startTime: number; + endTime: number; + skipDuration: number; + } | null>(null); const showingState = shouldShowSkipButton(time, props.skipTime); const animation = showingState === "hover" ? "slide-up" : "fade"; let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; @@ -59,20 +68,19 @@ export function SkipIntroButton(props: { : "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; } - const handleSkip = useCallback(() => { - if (typeof props.skipTime === "number" && display) { - const startTime = time; - const endTime = props.skipTime; - const skipDuration = endTime - startTime; + const { t } = useTranslation(); - display.setTime(props.skipTime); + const reportSkip = useCallback( + (confidence: number) => { + if (!pendingSkipDataRef.current) return; + + const { startTime, endTime, skipDuration } = pendingSkipDataRef.current; - // Add manual skip event with high confidence (user explicitly clicked skip intro) addSkipEvent({ startTime, endTime, skipDuration, - confidence: 0.95, // High confidence for explicit user action + confidence, meta: meta ? { title: @@ -87,15 +95,99 @@ export function SkipIntroButton(props: { : undefined, }); + // eslint-disable-next-line no-console + console.log( + `Skip intro reported: ${skipDuration}s total, confidence: ${confidence}`, + ); + + // Clean up + pendingSkipDataRef.current = null; + setShowFeedback(false); + setFeedbackSubmitted(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, + [addSkipEvent, meta], + ); + + const handleThumbsUp = useCallback(() => { + reportSkip(0.95); + }, [reportSkip]); + + const handleThumbsDown = useCallback(() => { + reportSkip(0.7); + }, [reportSkip]); + + const handleSkip = useCallback(() => { + if (typeof props.skipTime === "number" && display) { + const startTime = time; + const endTime = props.skipTime; + const skipDuration = endTime - startTime; + + display.setTime(props.skipTime); + + // Store skip data temporarily + pendingSkipDataRef.current = { + startTime, + endTime, + skipDuration, + }; + + // Show feedback UI + setShowFeedback(true); + setFeedbackSubmitted(false); + + // Start 10-second timeout + timeoutRef.current = setTimeout(() => { + // Hide component immediately to prevent flicker + setShowFeedback(false); + setFeedbackSubmitted(true); + reportSkip(0.8); + }, 10000); + // eslint-disable-next-line no-console console.log(`Skip intro button used: ${skipDuration}s total`); } - }, [props.skipTime, display, time, addSkipEvent, meta]); + }, [props.skipTime, display, time, reportSkip]); + + // Reset feedback state when content changes + useEffect(() => { + setShowFeedback(false); + setFeedbackSubmitted(false); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + pendingSkipDataRef.current = null; + }, [meta?.tmdbId]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + if (!props.inControl) return null; let show = false; - if (showingState === "always") show = true; - else if (showingState === "hover" && props.controlsShowing) show = true; + // Don't show anything if feedback has been submitted + if (feedbackSubmitted) { + show = false; + } else if (showFeedback) { + // Always show feedback UI when active + show = true; + } else if (showingState === "always") { + // Show skip button when always visible + show = true; + } else if (showingState === "hover" && props.controlsShowing) { + // Show skip button on hover when controls are showing + show = true; + } if (status !== "playing") show = false; return ( @@ -106,17 +198,52 @@ export function SkipIntroButton(props: { >
- + {showFeedback ? ( + <> +
+ {t("player.skipIntro.feedback")} +
+
+ + +
+ + ) : ( + + )}
); diff --git a/src/components/player/hooks/useSkipTime.ts b/src/components/player/hooks/useSkipTime.ts index 92e4a009..3dc37195 100644 --- a/src/components/player/hooks/useSkipTime.ts +++ b/src/components/player/hooks/useSkipTime.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; // import { proxiedFetch } from "@/backend/helpers/fetch"; +import { proxiedFetch } from "@/backend/helpers/fetch"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { conf } from "@/setup/config"; import { usePreferencesStore } from "@/stores/preferences"; @@ -9,6 +10,7 @@ import { getTurnstileToken } from "@/utils/turnstile"; // Thanks Nemo for this API const FED_SKIPS_BASE_URL = "https://fed-skips.pstream.mov"; // const VELORA_BASE_URL = "https://veloratv.ru/api/intro-end/confirmed"; +const INTRODB_BASE_URL = "https://api.introdb.app/intro"; const MAX_RETRIES = 3; export function useSkipTime() { @@ -82,19 +84,39 @@ export function useSkipTime() { } }; + const fetchIntroDBTime = async (): Promise => { + if (!meta?.imdbId || meta.type === "movie") return null; + + try { + const apiUrl = `${INTRODB_BASE_URL}?imdb_id=${meta.imdbId}&season=${meta.season?.number}&episode=${meta.episode?.number}`; + + const data = await proxiedFetch(apiUrl); + + if (data && typeof data.end_ms === "number") { + // Convert milliseconds to seconds + return Math.floor(data.end_ms / 1000); + } + + return null; + } catch (error) { + console.error("Error fetching IntroDB time:", error); + return null; + } + }; + const fetchSkipTime = async (): Promise => { // If user has febbox key, prioritize Fed-skips (better quality) if (febboxKey) { const fedSkipsTime = await fetchFedSkipsTime(); if (fedSkipsTime !== null) { setSkiptime(fedSkipsTime); - // return; + return; } } - // // Fall back to Velora API (available to all users) - // const veloraSkipTime = await fetchVeloraSkipTime(); - // setSkiptime(veloraSkipTime); + // Fall back to IntroDB API (available to all users) + const introDBTime = await fetchIntroDBTime(); + setSkiptime(introDBTime); }; fetchSkipTime(); diff --git a/src/components/player/hooks/useSkipTracking.ts b/src/components/player/hooks/useSkipTracking.ts index 9594b5f4..28706323 100644 --- a/src/components/player/hooks/useSkipTracking.ts +++ b/src/components/player/hooks/useSkipTracking.ts @@ -38,8 +38,8 @@ function calculateSkipConfidence( duration: number, ): number { // Duration confidence: longer skips are more confident - // 30s = 0.5, 60s = 0.75, 90s+ = 0.85 - const durationConfidence = Math.min(0.85, 0.5 + (skipDuration - 30) * 0.01); + // 20s = 0.4, 40s = 0.6, 60s+ = 0.85 + const durationConfidence = Math.min(0.85, 0.4 + (skipDuration - 20) * 0.01); // Timing confidence: earlier skips are more confident // Start time as percentage of total duration @@ -52,16 +52,16 @@ function calculateSkipConfidence( } /** - * Hook that tracks rapid skipping sessions where users accumulate 30+ seconds of forward + * Hook that tracks rapid skipping sessions where users accumulate 20+ 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 total forward movement in 5-second window to start session (default: 30) + * @param minSkipThreshold Minimum total forward movement in 5-second window to start session (default: 20) * @param maxHistory Maximum number of skip events to keep in history (default: 50) */ export function useSkipTracking( - minSkipThreshold: number = 30, + minSkipThreshold: number = 20, maxHistory: number = 50, ): SkipTrackingResult { const [skipHistory, setSkipHistory] = useState([]); @@ -114,12 +114,12 @@ export function useSkipTracking( const timeDelta = currentTime - previousTimeRef.current; - // Track forward movements >= 1 second in sliding 5-second window + // Track forward movements >= 1 second in sliding 6-second window if (timeDelta >= 1) { - // Add forward movement to window and remove entries older than 5 seconds + // Add forward movement to window and remove entries older than 6 seconds skipWindowRef.current.push({ time: now, delta: timeDelta }); skipWindowRef.current = skipWindowRef.current.filter( - (entry) => entry.time > now - 5000, + (entry) => entry.time > now - 6000, ); // Calculate total forward movement in current window diff --git a/src/components/player/internals/Backend/SkipTracker.tsx b/src/components/player/internals/Backend/SkipTracker.tsx index 7026d9d6..893e6dd9 100644 --- a/src/components/player/internals/Backend/SkipTracker.tsx +++ b/src/components/player/internals/Backend/SkipTracker.tsx @@ -8,8 +8,8 @@ type SkipEvent = NonNullable["latestSkip"]>; /** * 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. + * Sessions are detected when users accumulate 20+ seconds of forward movement + * within a 6-second window and end after 5 seconds of no activity. * Ignores skips that start after 20% of video duration (unlikely to be intro skipping). */ interface PendingSkip { @@ -22,7 +22,7 @@ interface PendingSkip { } export function SkipTracker() { - const { latestSkip } = useSkipTracking(30); + const { latestSkip } = useSkipTracking(20); const lastLoggedSkipRef = useRef(0); const [pendingSkips, setPendingSkips] = useState([]); const lastPlayerTimeRef = useRef(0); @@ -77,7 +77,7 @@ export function SkipTracker() { // Remove from pending return prev.filter((p) => p.skip.timestamp !== skip.timestamp); }); - }, 10000); // 10 second delay + }, 5000); // 5 second delay return { skip, @@ -101,7 +101,7 @@ export function SkipTracker() { // eslint-disable-next-line no-console console.log(`Skip session completed: ${latestSkip.skipDuration}s total`); - // Create pending skip with 10-second delay + // Create pending skip with 5-second delay const pendingSkip = createPendingSkip(latestSkip); setPendingSkips((prev) => [...prev, pendingSkip]); diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx index ce9a6fcc..8335f094 100644 --- a/src/components/player/internals/KeyboardEvents.tsx +++ b/src/components/player/internals/KeyboardEvents.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getMetaFromId } from "@/backend/metadata/getmeta"; import { MWMediaType } from "@/backend/metadata/types/mw"; @@ -14,6 +14,7 @@ import { useSubtitleStore } from "@/stores/subtitles"; import { useEmpheralVolumeStore } from "@/stores/volume"; import { useWatchPartyStore } from "@/stores/watchParty"; import { + DEFAULT_KEYBOARD_SHORTCUTS, LOCKED_SHORTCUTS, ShortcutId, matchesShortcut, @@ -49,7 +50,23 @@ export function KeyboardEvents() { (s) => s.setShowDelayIndicator, ); const enableHoldToBoost = usePreferencesStore((s) => s.enableHoldToBoost); - const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); + const storedKeyboardShortcuts = usePreferencesStore( + (s) => s.keyboardShortcuts, + ); + // Merge defaults with stored shortcuts to ensure new shortcuts are available + const keyboardShortcuts = useMemo( + () => ({ + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...storedKeyboardShortcuts, + }), + [storedKeyboardShortcuts], + ); + const enableNativeSubtitles = usePreferencesStore( + (s) => s.enableNativeSubtitles, + ); + const setEnableNativeSubtitles = usePreferencesStore( + (s) => s.setEnableNativeSubtitles, + ); const [isRolling, setIsRolling] = useState(false); const volumeDebounce = useRef | undefined>(); @@ -295,6 +312,8 @@ export function KeyboardEvents() { navigateToNextEpisode, navigateToPreviousEpisode, keyboardShortcuts, + enableNativeSubtitles, + setEnableNativeSubtitles, }); useEffect(() => { @@ -329,6 +348,8 @@ export function KeyboardEvents() { navigateToNextEpisode, navigateToPreviousEpisode, keyboardShortcuts, + enableNativeSubtitles, + setEnableNativeSubtitles, }; }, [ setShowVolume, @@ -356,6 +377,8 @@ export function KeyboardEvents() { navigateToNextEpisode, navigateToPreviousEpisode, keyboardShortcuts, + enableNativeSubtitles, + setEnableNativeSubtitles, ]); useEffect(() => { @@ -725,6 +748,20 @@ export function KeyboardEvents() { dataRef.current.setCurrentOverlay(null); }, 3000); } + + // Toggle native subtitles - customizable + const toggleNativeSubtitles = + dataRef.current.keyboardShortcuts[ShortcutId.TOGGLE_NATIVE_SUBTITLES]; + if ( + toggleNativeSubtitles?.key && + matchesShortcut(evt, toggleNativeSubtitles) + ) { + evt.preventDefault(); + evt.stopPropagation(); + dataRef.current.setEnableNativeSubtitles( + !dataRef.current.enableNativeSubtitles, + ); + } }; const keyupEventHandler = (evt: KeyboardEvent) => { diff --git a/src/utils/keyboardShortcuts.ts b/src/utils/keyboardShortcuts.ts index 543bd7c3..250a7bc6 100644 --- a/src/utils/keyboardShortcuts.ts +++ b/src/utils/keyboardShortcuts.ts @@ -40,6 +40,7 @@ export enum ShortcutId { RANDOM_CAPTION = "randomCaption", SYNC_SUBTITLES_EARLIER = "syncSubtitlesEarlier", SYNC_SUBTITLES_LATER = "syncSubtitlesLater", + TOGGLE_NATIVE_SUBTITLES = "toggleNativeSubtitles", // Interface BARREL_ROLL = "barrelRoll", @@ -67,6 +68,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { [ShortcutId.RANDOM_CAPTION]: { modifier: "Shift", key: "C" }, [ShortcutId.SYNC_SUBTITLES_EARLIER]: { key: "[" }, [ShortcutId.SYNC_SUBTITLES_LATER]: { key: "]" }, + [ShortcutId.TOGGLE_NATIVE_SUBTITLES]: { key: "S" }, [ShortcutId.BARREL_ROLL]: { key: "R" }, };