Merge branch 'production' into production

This commit is contained in:
vlOd 2026-01-03 15:55:09 +02:00 committed by GitHub
commit 7716ec0058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 251 additions and 40 deletions

View file

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

View file

@ -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<Icons, string> = {
repeat: `<svg viewBox="0 0 24 24" width="1em" height="1em" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>`,
plus: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1em" height="1em" fill="currentColor"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M352 128C352 110.3 337.7 96 320 96C302.3 96 288 110.3 288 128L288 288L128 288C110.3 288 96 302.3 96 320C96 337.7 110.3 352 128 352L288 352L288 512C288 529.7 302.3 544 320 544C337.7 544 352 529.7 352 512L352 352L512 352C529.7 352 544 337.7 544 320C544 302.3 529.7 288 512 288L352 288L352 128z"/></svg>`,
translate: `<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 52 52" data-name="Layer 1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"><path d="M39,18.67H35.42l-4.2,11.12A29,29,0,0,1,20.6,24.91a28.76,28.76,0,0,0,7.11-14.49h5.21a2,2,0,0,0,0-4H19.67V2a2,2,0,1,0-4,0V6.42H2.41a2,2,0,0,0,0,4H7.63a28.73,28.73,0,0,0,7.1,14.49A29.51,29.51,0,0,1,3.27,30a2,2,0,0,0,.43,4,1.61,1.61,0,0,0,.44-.05,32.56,32.56,0,0,0,13.53-6.25,32,32,0,0,0,12.13,5.9L22.83,52H28l2.7-7.76H43.64L46.37,52h5.22Zm-15.3-8.25a23.76,23.76,0,0,1-6,11.86,23.71,23.71,0,0,1-6-11.86Zm8.68,29.15,4.83-13.83L42,39.57Z"/></svg>`,
thumbsUp: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M144 224C161.7 224 176 238.3 176 256L176 512C176 529.7 161.7 544 144 544L96 544C78.3 544 64 529.7 64 512L64 256C64 238.3 78.3 224 96 224L144 224zM334.6 80C361.9 80 384 102.1 384 129.4L384 133.6C384 140.4 382.7 147.2 380.2 153.5L352 224L512 224C538.5 224 560 245.5 560 272C560 291.7 548.1 308.6 531.1 316C548.1 323.4 560 340.3 560 360C560 383.4 543.2 402.9 521 407.1C525.4 414.4 528 422.9 528 432C528 454.2 513 472.8 492.6 478.3C494.8 483.8 496 489.8 496 496C496 522.5 474.5 544 448 544L360.1 544C323.8 544 288.5 531.6 260.2 508.9L248 499.2C232.8 487.1 224 468.7 224 449.2L224 262.6C224 247.7 227.5 233 234.1 219.7L290.3 107.3C298.7 90.6 315.8 80 334.6 80z"/></svg>`,
thumbsDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M448 96C474.5 96 496 117.5 496 144C496 150.3 494.7 156.2 492.6 161.7C513 167.2 528 185.8 528 208C528 217.1 525.4 225.6 521 232.9C543.2 237.1 560 256.6 560 280C560 299.7 548.1 316.6 531.1 324C548.1 331.4 560 348.3 560 368C560 394.5 538.5 416 512 416L352 416L380.2 486.4C382.7 492.7 384 499.5 384 506.3L384 510.5C384 537.8 361.9 559.9 334.6 559.9C315.9 559.9 298.8 549.3 290.4 532.6L234.1 420.3C227.4 407 224 392.3 224 377.4L224 190.8C224 171.4 232.9 153 248 140.8L260.2 131.1C288.6 108.4 323.8 96 360.1 96L448 96zM144 160C161.7 160 176 174.3 176 192L176 448C176 465.7 161.7 480 144 480L96 480C78.3 480 64 465.7 64 448L64 192C64 174.3 78.3 160 96 160L144 160z"/></svg>`,
};
export const Icon = memo((props: IconProps) => {

View file

@ -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",
),
},
],
},
{

View file

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

View file

@ -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<ReturnType<typeof setTimeout> | 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: {
>
<div
className={classNames([
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center",
showFeedback ? "flex-col space-y-2" : "space-x-3",
bottom,
])}
>
<Button
onClick={handleSkip}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
>
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
Skip Intro
</Button>
{showFeedback ? (
<>
<div className="text-sm font-medium text-white">
{t("player.skipIntro.feedback")}
</div>
<div className="flex items-center space-x-3">
<button
type="button"
onClick={handleThumbsUp}
className={classNames(
"h-10 w-10 rounded-full flex items-center justify-center",
"bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText",
"scale-95 hover:scale-100 transition-all duration-200",
)}
aria-label="Thumbs up"
>
<Icon className="text-xl" icon={Icons.THUMBS_UP} />
</button>
<button
type="button"
onClick={handleThumbsDown}
className={classNames(
"h-10 w-10 rounded-full flex items-center justify-center",
"bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText",
"scale-95 hover:scale-100 transition-all duration-200",
)}
aria-label="Thumbs down"
>
<Icon className="text-xl" icon={Icons.THUMBS_DOWN} />
</button>
</div>
</>
) : (
<Button
onClick={handleSkip}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
>
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
{t("player.skipIntro.skip")}
</Button>
)}
</div>
</Transition>
);

View file

@ -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<number | null> => {
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<void> => {
// 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();

View file

@ -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<SkipEvent[]>([]);
@ -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

View file

@ -8,8 +8,8 @@ type SkipEvent = NonNullable<ReturnType<typeof useSkipTracking>["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<number>(0);
const [pendingSkips, setPendingSkips] = useState<PendingSkip[]>([]);
const lastPlayerTimeRef = useRef<number>(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]);

View file

@ -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<ReturnType<typeof setTimeout> | 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) => {

View file

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