thumbs up or down skip intros

This commit is contained in:
Pas 2026-01-02 13:53:11 -07:00
parent 7370b4b5b0
commit 7ea4b1d23b
3 changed files with 155 additions and 20 deletions

View file

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

@ -83,6 +83,8 @@ export enum Icons {
RELOAD = "reload",
REPEAT = "repeat",
PLUS = "plus",
THUMBS_UP = "thumbsUp",
THUMBS_DOWN = "thumbsDown",
}
export interface IconProps {
@ -183,6 +185,8 @@ const iconList: Record<Icons, string> = {
reload: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M544.1 256L552 256C565.3 256 576 245.3 576 232L576 88C576 78.3 570.2 69.5 561.2 65.8C552.2 62.1 541.9 64.2 535 71L483.3 122.8C439 86.1 382 64 320 64C191 64 84.3 159.4 66.6 283.5C64.1 301 76.2 317.2 93.7 319.7C111.2 322.2 127.4 310 129.9 292.6C143.2 199.5 223.3 128 320 128C364.4 128 405.2 143 437.7 168.3L391 215C384.1 221.9 382.1 232.2 385.8 241.2C389.5 250.2 398.3 256 408 256L544.1 256zM573.5 356.5C576 339 563.8 322.8 546.4 320.3C529 317.8 512.7 330 510.2 347.4C496.9 440.4 416.8 511.9 320.1 511.9C275.7 511.9 234.9 496.9 202.4 471.6L249 425C255.9 418.1 257.9 407.8 254.2 398.8C250.5 389.8 241.7 384 232 384L88 384C74.7 384 64 394.7 64 408L64 552C64 561.7 69.8 570.5 78.8 574.2C87.8 577.9 98.1 575.8 105 569L156.8 517.2C201 553.9 258 576 320 576C449 576 555.7 480.6 573.4 356.5z"/></svg>`,
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>`,
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

@ -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";
@ -50,6 +51,14 @@ export function SkipIntroButton(props: {
const display = usePlayerStore((s) => s.display);
const meta = usePlayerStore((s) => s.meta);
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,
])}
>
{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} />
Skip Intro
{t("player.skipIntro.skip")}
</Button>
)}
</div>
</Transition>
);